Persistence: share entity write-through helper; normalize quota at db.put

This commit is contained in:
2026-06-16 23:06:37 +03:00
parent 778b4d848a
commit 2ed9db3792
18 changed files with 422 additions and 205 deletions
+42 -2
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest';
import { StorageQuotaError } from '../infrastructure/snippet-store';
import { storageErrorNotification } from './storage-errors';
import { StorageQuotaError } from '../infrastructure/db';
import { entityStorageErrorNotification, storageErrorNotification } from './storage-errors';
describe('storageErrorNotification', () => {
describe('a failure the user can fix (storage full)', () => {
@@ -43,3 +43,43 @@ describe('storageErrorNotification', () => {
});
});
});
describe('entityStorageErrorNotification', () => {
it('names the entity in the title, message, and diagnostic', () => {
const save = entityStorageErrorNotification('dataset', 'save', new Error('boom'));
expect(save.kind).toBe('error');
expect(save.title).toBe("Couldn't save the dataset");
expect(save.message).toContain('dataset change');
expect(save.detail).toBe('Dataset save failed: Error: boom'); // capitalized noun in trace
const del = entityStorageErrorNotification('font', 'delete', new Error('boom'));
expect(del.title).toBe("Couldn't delete the font");
expect(del.detail).toContain('Font delete failed');
});
it('detects a quota failure for any entity tier (not just snippets)', () => {
const n = entityStorageErrorNotification('dataset', 'save', new StorageQuotaError());
expect(n.title).toBe('Storage full');
expect(n.message.toLowerCase()).toContain('dataset');
expect(n.message.toLowerCase()).toContain('delete');
expect(n.detail).toBeUndefined(); // nothing to report — the fix is in the message
});
it('frames a load failure with the entity noun, not "snippets"', () => {
const n = entityStorageErrorNotification(
'dataset',
'load',
new DOMException('blocked', 'SecurityError'),
);
expect(n.title).toBe("Couldn't open your datasets");
expect(n.message).toContain('datasets');
expect(n.message).not.toContain('snippet');
expect(n.detail).toContain('load');
expect(n.detail).toContain('SecurityError');
});
it('describes a non-Error throw without crashing', () => {
const n = entityStorageErrorNotification('theme', 'save', 'weird string');
expect(n.detail).toBe('weird string');
});
});
+56 -1
View File
@@ -13,7 +13,7 @@
* that failed + the underlying error) so it can be reported and traced.
*/
import { StorageQuotaError } from '../infrastructure/snippet-store';
import { StorageQuotaError } from '../infrastructure/db';
import type { NotifyOptions } from '../stores/NotificationStore';
/** Which persistence operation failed — shapes both the wording and the trace. */
@@ -73,3 +73,58 @@ export function storageErrorNotification(op: StorageOp, err: unknown): NotifyOpt
detail: diagnostic(op, err),
};
}
/**
* The storage-failure notification for the per-record entity tiers other than
* snippets (datasets, themes, fonts), parameterized by entity noun. Mirrors the
* snippet-flavored `storageErrorNotification` above — quota detection, the startup
* `load` failure, and the generic save/delete failure — but with the entity's own
* noun so a dataset error never reads "snippet". Snippets keep the bespoke function
* above (its "your library" / "your changes" framing is tuned to the silent
* save-on-edit model). `noun` is the lowercase singular ("dataset").
*/
export function entityStorageErrorNotification(
noun: string,
op: StorageOp,
err: unknown,
): NotifyOptions {
const Noun = noun.charAt(0).toUpperCase() + noun.slice(1);
const plural = `${noun}s`;
const detail =
err instanceof Error ? `${Noun} ${op} failed: ${err.name}: ${err.message}` : String(err);
// Storage full — the one failure the user can act on. No detail: the next step
// is in the message. (Quota is whole-origin; per-tier framing is a parked UX
// nuance — see docs/ux-second-pass.md.)
if (err instanceof StorageQuotaError) {
return {
kind: 'error',
title: 'Storage full',
message:
`This ${noun} couldn't be saved because ${noun} storage is full. ` +
`Delete ${plural} you no longer need to free space, then try again.`,
};
}
// Couldn't read this tier on startup — almost always blocked storage.
if (op === 'load') {
return {
kind: 'error',
title: `Couldn't open your ${plural}`,
message:
`Astrolabe couldn't open local storage, so your saved ${plural} aren't available yet. ` +
'This usually means the browser is blocking storage — for example, private-browsing mode. ' +
'Reload to try again.',
detail,
};
}
return {
kind: 'error',
title: op === 'delete' ? `Couldn't delete the ${noun}` : `Couldn't save the ${noun}`,
message:
`A storage error stopped Astrolabe from completing the last ${noun} change, so it may not ` +
'survive a reload. If this keeps happening, your browser may be blocking local storage.',
detail,
};
}
+2 -1
View File
@@ -24,7 +24,8 @@ import { createCustomTheme } from '@core/custom-theme';
import { createDataset } from '@core/dataset';
import { createSnippet } from '@core/snippet';
import { downloadJson, readTextFile } from '../infrastructure/file-transfer';
import { deleteSnippet, saveSnippet, StorageQuotaError } from '../infrastructure/snippet-store';
import { deleteSnippet, saveSnippet } from '../infrastructure/snippet-store';
import { StorageQuotaError } from '../infrastructure/db';
import { useCustomThemeStore } from '../stores/CustomThemeStore';
import { useDatasetStore } from '../stores/DatasetStore';
import { useNotificationStore } from '../stores/NotificationStore';
+2 -1
View File
@@ -29,7 +29,8 @@ import {
import { snippetSizeBytes } from '@core/snippet';
import { humanizeBytes } from '@core/storage-estimate';
import { downloadJson, readTextFile } from '../infrastructure/file-transfer';
import { deleteSnippet, saveSnippet, StorageQuotaError } from '../infrastructure/snippet-store';
import { deleteSnippet, saveSnippet } from '../infrastructure/snippet-store';
import { StorageQuotaError } from '../infrastructure/db';
import { notify } from '../stores/NotificationStore';
import { useCustomThemeStore } from '../stores/CustomThemeStore';
import { useDatasetStore } from '../stores/DatasetStore';