smoodly 0.0.28 → 0.0.29
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +48 -8
- package/dist/admin/client-ops.js +1 -0
- package/dist/admin/drift-copy.d.ts +19 -0
- package/dist/admin/drift-copy.js +89 -0
- package/dist/admin/drift.d.ts +76 -0
- package/dist/admin/drift.js +264 -0
- package/dist/admin/editor/EditorView.js +67 -15
- package/dist/admin/editor/InlineRichtext.js +3 -1
- package/dist/admin/editor/Outline.d.ts +7 -0
- package/dist/admin/editor/Outline.js +8 -5
- package/dist/admin/editor/PageInspector.d.ts +6 -1
- package/dist/admin/editor/PageInspector.js +4 -2
- package/dist/admin/editor/PageSettings.js +26 -16
- package/dist/admin/editor/UnknownNode.d.ts +7 -0
- package/dist/admin/editor/UnknownNode.js +8 -0
- package/dist/admin/field-paths.d.ts +3 -0
- package/dist/admin/field-paths.js +26 -0
- package/dist/admin/forms/OrphanFields.d.ts +8 -0
- package/dist/admin/forms/OrphanFields.js +11 -0
- package/dist/admin/forms/RefPicker.js +6 -2
- package/dist/admin/forms/RichtextElementCard.js +9 -3
- package/dist/admin/forms/RichtextWidget.js +3 -1
- package/dist/admin/forms/TagsField.js +1 -1
- package/dist/admin/forms/richtext-form-context.d.ts +8 -0
- package/dist/admin/forms/richtext-form-context.js +5 -0
- package/dist/admin/health.d.ts +23 -0
- package/dist/admin/health.js +42 -0
- package/dist/admin/ops-impl.js +40 -9
- package/dist/admin/ops.d.ts +13 -0
- package/dist/admin/shell/AddRedirectModal.js +1 -1
- package/dist/admin/shell/ContentHealthPage.d.ts +10 -0
- package/dist/admin/shell/ContentHealthPage.js +37 -0
- package/dist/admin/shell/EntriesList.js +4 -4
- package/dist/admin/shell/EntryForm.js +20 -3
- package/dist/admin/shell/ErrorPane.d.ts +4 -2
- package/dist/admin/shell/ErrorPane.js +5 -4
- package/dist/admin/shell/GlobalForm.js +22 -2
- package/dist/admin/shell/PagesList.js +3 -3
- package/dist/admin/shell/SettingsView.js +6 -2
- package/dist/admin/shell/health-list.d.ts +27 -0
- package/dist/admin/shell/health-list.js +47 -0
- package/dist/admin/shell/settings.d.ts +4 -0
- package/dist/admin/shell/settings.js +5 -2
- package/dist/admin/tree-ops.d.ts +4 -0
- package/dist/admin/tree-ops.js +15 -1
- package/dist/admin/ui/Combobox.d.ts +8 -2
- package/dist/admin/ui/Combobox.js +48 -16
- package/dist/admin/ui/ConfirmDialog.js +3 -3
- package/dist/admin/ui/DriftNotice.d.ts +6 -0
- package/dist/admin/ui/DriftNotice.js +10 -0
- package/dist/admin/ui/combo-box.d.ts +13 -0
- package/dist/admin/ui/combo-box.js +8 -0
- package/dist/admin/ui/confirm.d.ts +8 -0
- package/dist/admin/ui/confirm.js +17 -4
- package/dist/admin/ui/theme.js +29 -3
- package/dist/admin/validate.js +15 -47
- package/dist/entry-store.d.ts +13 -0
- package/dist/entry-store.js +26 -3
- package/dist/index.d.ts +2 -2
- package/dist/render.js +7 -2
- package/dist/store.d.ts +9 -0
- package/dist/store.js +8 -0
- package/dist/supabase-entry-store.d.ts +2 -1
- package/dist/supabase-entry-store.js +27 -5
- package/dist/supabase-store.d.ts +2 -1
- package/dist/supabase-store.js +27 -0
- package/package.json +1 -1
package/dist/entry-store.d.ts
CHANGED
|
@@ -40,6 +40,12 @@ export type EntryVersion = {
|
|
|
40
40
|
/** When the content last changed: the unpublished draft row is rewritten in place (spec 2026-09-13 history §1). */
|
|
41
41
|
savedAt: number;
|
|
42
42
|
};
|
|
43
|
+
/** One entry row for the drift scan (spec 2026-09-27 editor safety §3), whatever its collection:
|
|
44
|
+
* locale → the live version's merged fields, null where the locale is not published. */
|
|
45
|
+
export type EntryScan = {
|
|
46
|
+
record: EntryRecord;
|
|
47
|
+
live: Record<string, Record<string, unknown> | null>;
|
|
48
|
+
};
|
|
43
49
|
export type EntryUsage = {
|
|
44
50
|
sourceKind: string;
|
|
45
51
|
sourceId: string;
|
|
@@ -96,6 +102,8 @@ export interface EntryStore {
|
|
|
96
102
|
create(collection: string, fields: Record<string, unknown>, options: CreateEntryOptions, by?: string): Promise<EntryRecord>;
|
|
97
103
|
get(collection: string, id: string): Promise<EntryRecord | null>;
|
|
98
104
|
list(collection: string, options?: ListOptions): Promise<EntryRecord[]>;
|
|
105
|
+
/** Every entry row, registered collection or not, with each published locale's live fields (spec 2026-09-27 editor safety §3). */
|
|
106
|
+
scanEntries(): Promise<EntryScan[]>;
|
|
99
107
|
/** Batched read for the resolver: the MERGED fields in `locale`, keyed
|
|
100
108
|
* by id. An id that is missing, absent in the locale, or (with
|
|
101
109
|
* `status`) unpublished there is omitted. With `{ status: "published" }`
|
|
@@ -203,6 +211,10 @@ export type SplitFields = {
|
|
|
203
211
|
* Every key that is neither localized nor `slug` is shared — declared or
|
|
204
212
|
* not — so a stray key never disappears into a locale row. */
|
|
205
213
|
export declare function splitFields(fields: Record<string, unknown>, schema: CollectionSchema<any>): SplitFields;
|
|
214
|
+
/** A localized key's value still in the shared fields — saved while the field was shared — stays
|
|
215
|
+
* there as every locale's fallback: a write never deletes it (spec 2026-09-27 editor safety §7).
|
|
216
|
+
* Reads merge shared, then the row, so each locale shows its own value once it has one. */
|
|
217
|
+
export declare function keepLocalizedShared(before: Record<string, unknown>, shared: Record<string, unknown>, schema: CollectionSchema<any>): Record<string, unknown>;
|
|
206
218
|
export declare function entryLocaleRow(record: EntryRecord, locale: string): EntryLocale;
|
|
207
219
|
/** The merged view of one locale: shared + that locale's fields + its
|
|
208
220
|
* slug. A fresh object; throws for an absent locale. */
|
|
@@ -285,6 +297,7 @@ export declare class MemoryEntryStore implements EntryStore {
|
|
|
285
297
|
discardDraft(collection: string, id: string, locale: string, by?: string, expected?: DraftToken | null): Promise<EntryRecord>;
|
|
286
298
|
addLocale(collection: string, id: string, locale: string, options: AddEntryLocaleOptions, by?: string): Promise<EntryRecord>;
|
|
287
299
|
removeLocale(collection: string, id: string, locale: string): Promise<EntryRecord>;
|
|
300
|
+
scanEntries(): Promise<EntryScan[]>;
|
|
288
301
|
listVersions(collection: string, id: string, locale: string): Promise<EntryVersion[]>;
|
|
289
302
|
restore(collection: string, id: string, locale: string, versionId: string, by?: string, expected?: DraftToken | null): Promise<EntryRecord>;
|
|
290
303
|
getVersion(collection: string, versionId: string): Promise<EntryVersion | null>;
|
package/dist/entry-store.js
CHANGED
|
@@ -98,6 +98,17 @@ export function splitFields(fields, schema) {
|
|
|
98
98
|
}
|
|
99
99
|
return { shared: structuredClone(shared), localized: structuredClone(localized), slug: slugOf(fields) };
|
|
100
100
|
}
|
|
101
|
+
/** A localized key's value still in the shared fields — saved while the field was shared — stays
|
|
102
|
+
* there as every locale's fallback: a write never deletes it (spec 2026-09-27 editor safety §7).
|
|
103
|
+
* Reads merge shared, then the row, so each locale shows its own value once it has one. */
|
|
104
|
+
export function keepLocalizedShared(before, shared, schema) {
|
|
105
|
+
const out = { ...shared };
|
|
106
|
+
for (const key of localizedKeys(schema)) {
|
|
107
|
+
if (key in before && !(key in out))
|
|
108
|
+
out[key] = structuredClone(before[key]);
|
|
109
|
+
}
|
|
110
|
+
return out;
|
|
111
|
+
}
|
|
101
112
|
export function entryLocaleRow(record, locale) {
|
|
102
113
|
const row = record.locales[locale];
|
|
103
114
|
if (!row)
|
|
@@ -408,7 +419,7 @@ export class MemoryEntryStore {
|
|
|
408
419
|
throw entryErrors.noSlug(collection);
|
|
409
420
|
this.assertSiblingSlug(collection, entry.parentId, locale, slug, id);
|
|
410
421
|
const before = { fields: entry.fields, row: { ...row } };
|
|
411
|
-
entry.fields = shared;
|
|
422
|
+
entry.fields = keepLocalizedShared(before.fields, shared, schema);
|
|
412
423
|
row.fields = localized;
|
|
413
424
|
row.slug = slug;
|
|
414
425
|
try {
|
|
@@ -449,7 +460,7 @@ export class MemoryEntryStore {
|
|
|
449
460
|
if (!snapshot)
|
|
450
461
|
throw entryErrors.nothingPublished(collection, id, locale);
|
|
451
462
|
const { shared, localized } = splitFields(snapshot.fields, schema);
|
|
452
|
-
entry.fields = shared;
|
|
463
|
+
entry.fields = keepLocalizedShared(entry.fields, shared, schema);
|
|
453
464
|
row.fields = localized;
|
|
454
465
|
const abandoned = row.draftVersionId;
|
|
455
466
|
row.draftVersionId = row.publishedVersionId;
|
|
@@ -517,6 +528,18 @@ export class MemoryEntryStore {
|
|
|
517
528
|
this.writeRefs(entry);
|
|
518
529
|
return this.snapshot(entry);
|
|
519
530
|
}
|
|
531
|
+
async scanEntries() {
|
|
532
|
+
return [...this.entries.values()].map((entry) => ({
|
|
533
|
+
record: this.snapshot(entry),
|
|
534
|
+
live: Object.fromEntries(Object.entries(entry.locales).map(([l, row]) => {
|
|
535
|
+
if (row.status !== "published")
|
|
536
|
+
return [l, null];
|
|
537
|
+
// A legacy published row without a snapshot serves its live row (spec 2026-09-14 §1).
|
|
538
|
+
const snap = row.publishedVersionId ? this.versions.get(row.publishedVersionId) : undefined;
|
|
539
|
+
return [l, snap ? structuredClone(snap.fields) : entryFieldsIn(entry, l)];
|
|
540
|
+
})),
|
|
541
|
+
}));
|
|
542
|
+
}
|
|
520
543
|
async listVersions(collection, id, locale) {
|
|
521
544
|
this.must(collection, id);
|
|
522
545
|
return [...this.versions.values()]
|
|
@@ -535,7 +558,7 @@ export class MemoryEntryStore {
|
|
|
535
558
|
if (versionId === row.publishedVersionId)
|
|
536
559
|
return this.discardDraft(collection, id, locale, by, expected);
|
|
537
560
|
const { shared, localized } = splitFields(version.fields, schema);
|
|
538
|
-
entry.fields = shared;
|
|
561
|
+
entry.fields = keepLocalizedShared(entry.fields, shared, schema);
|
|
539
562
|
row.fields = localized; // the slug is the row's own and never versioned
|
|
540
563
|
this.recordVersion(entry, locale);
|
|
541
564
|
this.bump(entry, row, by);
|
package/dist/index.d.ts
CHANGED
|
@@ -51,7 +51,7 @@ export type { PathIndex } from "./path-index.ts";
|
|
|
51
51
|
export { MemoryEntryStore } from "./entry-store.ts";
|
|
52
52
|
export { SupabaseEntryStore } from "./supabase-entry-store.ts";
|
|
53
53
|
export { SupabasePathIndex } from "./supabase-path-index.ts";
|
|
54
|
-
export type { EntryStore, EntryRecord, EntryLocale, EntryVersion, EntryUsage, ListOptions, CreateEntryOptions, AddEntryLocaleOptions, EntryStoreOptions, Where, WhereValue, } from "./entry-store.ts";
|
|
54
|
+
export type { EntryStore, EntryRecord, EntryLocale, EntryVersion, EntryScan, EntryUsage, ListOptions, CreateEntryOptions, AddEntryLocaleOptions, EntryStoreOptions, Where, WhereValue, } from "./entry-store.ts";
|
|
55
55
|
export { entryErrors, compareEntries, DEFAULT_ENTRY_LOCALES, localizedKeys, splitFields, entryLocaleRow, entryFieldsIn, entryRefEdges, entryTitleIn, matchesWhere, } from "./entry-store.ts";
|
|
56
56
|
export { affectedTargets, pageTag, entryTag, globalTag, pagesTreeTag, collectionTag } from "./revalidate.ts";
|
|
57
57
|
export type { AffectedTargets } from "./revalidate.ts";
|
|
@@ -60,7 +60,7 @@ export type { EntryFetcher, ResolveFieldsOptions, AssetFetcher } from "./resolve
|
|
|
60
60
|
export { SEGMENT_RE, isSegment, assertSegment, joinPath, splitPath, segmentFor, isHomeNode, pagePathIn, chainOf, assertDepth, pagePath, pagePathRows, entryPathRows, collectionSegment, buildPageTree, perLocaleSegments, assertSupportedLocale, unsupportedLocale, pageAlternates, entryAlternates, } from "./paths.ts";
|
|
61
61
|
export type { LocaleSet, PathTarget, PathRow, PageLike, EntryLike, PageTreeNode, PagePathOptions, LocaleAlternate, PageAlternate } from "./paths.ts";
|
|
62
62
|
export { fixedPathFor } from "./localize.ts";
|
|
63
|
-
export type { PageStore, PageRecord, PageLocale, PageVersion, PageStoreOptions, CreatePageInput, AddLocaleOptions } from "./store.ts";
|
|
63
|
+
export type { PageStore, PageRecord, PageLocale, PageVersion, PageScan, PageStoreOptions, CreatePageInput, AddLocaleOptions } from "./store.ts";
|
|
64
64
|
export { pathOptions, pageErrors, localeRow, pageRefEdges, DEFAULT_LOCALES } from "./store.ts";
|
|
65
65
|
export type { SmoodlyConfig, ResolvedConfig } from "./config.ts";
|
|
66
66
|
export type { FieldBuilder, Descriptor, ValueOf, Option, OptionInput, OptionValue } from "./fields.ts";
|
package/dist/render.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
-
import { EMPTY_ATTR, ZONE_ATTR } from "./marks.js";
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import { EMPTY_ATTR, NODE_ATTR, ZONE_ATTR } from "./marks.js";
|
|
3
3
|
import { SMD_KEY } from "./section-wrapper.js";
|
|
4
4
|
import { DEFAULT_STYLE_VALUES } from "./styles.js";
|
|
5
5
|
import { CUSTOM_NODE_TYPE, createCustomSection } from "./custom/index.js";
|
|
@@ -23,6 +23,11 @@ export function Zone({ of: content }) {
|
|
|
23
23
|
return (_jsx(_Fragment, { children: nodes.map((node) => {
|
|
24
24
|
const Component = content.components.get(node.type);
|
|
25
25
|
if (!Component) {
|
|
26
|
+
// Editor mode draws a box the bridge can measure, so the node is selectable in place
|
|
27
|
+
// (spec 2026-09-27 editor safety §5); the site renders nothing, as before.
|
|
28
|
+
if (content.editor) {
|
|
29
|
+
return (_jsxs("div", { [NODE_ATTR]: node.id, style: { padding: "24px 16px", border: "1px dashed #b54708", color: "#b54708", font: "500 14px/1.4 system-ui, sans-serif", textAlign: "center" }, children: ["Unknown section `", node.type, "`"] }, node.id));
|
|
30
|
+
}
|
|
26
31
|
console.warn(`smoodly: no registered component for node type "${node.type}" — skipping.`);
|
|
27
32
|
return null;
|
|
28
33
|
}
|
package/dist/store.d.ts
CHANGED
|
@@ -52,10 +52,18 @@ export type PageVersion = {
|
|
|
52
52
|
tree: PageTree;
|
|
53
53
|
savedAt: number;
|
|
54
54
|
};
|
|
55
|
+
/** One page for the drift scan (spec 2026-09-27 editor safety §3): locale → draft and live tree; null where there is none. */
|
|
56
|
+
export type PageScan = {
|
|
57
|
+
record: PageRecord;
|
|
58
|
+
draft: Record<string, PageTree | null>;
|
|
59
|
+
live: Record<string, PageTree | null>;
|
|
60
|
+
};
|
|
55
61
|
export interface PageStore {
|
|
56
62
|
createPage(input: CreatePageInput, by?: string): Promise<PageRecord>;
|
|
57
63
|
getPage(id: string): Promise<PageRecord | null>;
|
|
58
64
|
listPages(): Promise<PageRecord[]>;
|
|
65
|
+
/** Every page with each locale's draft and live tree, in few reads (spec 2026-09-27 editor safety §3). */
|
|
66
|
+
scanTrees(): Promise<PageScan[]>;
|
|
59
67
|
resolvePath(locale: string, path: string): Promise<PathHit | null>;
|
|
60
68
|
/** The database's schema version from `smoodly_meta`; null when the table is missing (older than v1). Memory: always current (spec 2026-09-26 §5). */
|
|
61
69
|
schemaVersion(): Promise<number | null>;
|
|
@@ -212,6 +220,7 @@ export declare class MemoryPageStore implements PageStore {
|
|
|
212
220
|
private dropVersions;
|
|
213
221
|
createPage(input: CreatePageInput, by?: string): Promise<PageRecord>;
|
|
214
222
|
getPage(id: string): Promise<PageRecord | null>;
|
|
223
|
+
scanTrees(): Promise<PageScan[]>;
|
|
215
224
|
listPages(): Promise<PageRecord[]>;
|
|
216
225
|
resolvePath(locale: string, path: string): Promise<PathHit | null>;
|
|
217
226
|
schemaVersion(): Promise<number>;
|
package/dist/store.js
CHANGED
|
@@ -277,6 +277,14 @@ export class MemoryPageStore {
|
|
|
277
277
|
const page = this.pages.get(id);
|
|
278
278
|
return page ? this.snapshot(page) : null;
|
|
279
279
|
}
|
|
280
|
+
async scanTrees() {
|
|
281
|
+
const tree = (id) => (id ? structuredClone(this.versions.get(id)?.tree ?? null) : null);
|
|
282
|
+
return (await this.listPages()).map((record) => ({
|
|
283
|
+
record,
|
|
284
|
+
draft: Object.fromEntries(Object.entries(record.locales).map(([l, row]) => [l, tree(row.draftVersionId)])),
|
|
285
|
+
live: Object.fromEntries(Object.entries(record.locales).map(([l, row]) => [l, tree(row.publishedVersionId)])),
|
|
286
|
+
}));
|
|
287
|
+
}
|
|
280
288
|
async listPages() {
|
|
281
289
|
return [...this.pages.values()]
|
|
282
290
|
// null sorts last; the SQL equivalent is ORDER BY sort ASC NULLS LAST, created_at ASC.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { SupabaseClient } from "@supabase/supabase-js";
|
|
2
2
|
import type { CollectionSchema } from "./collections.ts";
|
|
3
|
-
import type { AddEntryLocaleOptions, CreateEntryOptions, EntryRecord, EntryStore, EntryStoreOptions, EntryUsage, EntryVersion, ListOptions } from "./entry-store.ts";
|
|
3
|
+
import type { AddEntryLocaleOptions, CreateEntryOptions, EntryRecord, EntryStore, EntryStoreOptions, EntryScan, EntryUsage, EntryVersion, ListOptions } from "./entry-store.ts";
|
|
4
4
|
import { type DraftToken } from "./draft-token.ts";
|
|
5
5
|
export declare class SupabaseEntryStore implements EntryStore {
|
|
6
6
|
private db;
|
|
@@ -68,6 +68,7 @@ export declare class SupabaseEntryStore implements EntryStore {
|
|
|
68
68
|
discardDraft(collection: string, id: string, locale: string, by?: string, expected?: DraftToken | null): Promise<EntryRecord>;
|
|
69
69
|
addLocale(collection: string, id: string, locale: string, options: AddEntryLocaleOptions, by?: string): Promise<EntryRecord>;
|
|
70
70
|
removeLocale(collection: string, id: string, locale: string): Promise<EntryRecord>;
|
|
71
|
+
scanEntries(): Promise<EntryScan[]>;
|
|
71
72
|
listVersions(collection: string, id: string, locale: string): Promise<EntryVersion[]>;
|
|
72
73
|
restore(collection: string, id: string, locale: string, versionId: string, by?: string, expected?: DraftToken | null): Promise<EntryRecord>;
|
|
73
74
|
getVersion(collection: string, versionId: string): Promise<EntryVersion | null>;
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// calls the smoodly_reorder SQL function: one statement, updated_at
|
|
8
8
|
// untouched.
|
|
9
9
|
import { collectionDepth, collectionOrder } from "./collections.js";
|
|
10
|
-
import { ARRAY_FIELD_TYPES, DEFAULT_ENTRY_LOCALES, entryErrors, entryFieldsIn, entryLocaleRow, entryRefEdges, entryTitleIn, isGlobalSchema, schemaOf, splitFields, } from "./entry-store.js";
|
|
10
|
+
import { ARRAY_FIELD_TYPES, DEFAULT_ENTRY_LOCALES, entryErrors, entryFieldsIn, entryLocaleRow, entryRefEdges, entryTitleIn, isGlobalSchema, keepLocalizedShared, schemaOf, splitFields, } from "./entry-store.js";
|
|
11
11
|
import { globalEntryId } from "./global-id.js";
|
|
12
12
|
import { SupabasePathIndex } from "./supabase-path-index.js";
|
|
13
13
|
import { groupRedirects } from "./store.js";
|
|
@@ -473,11 +473,12 @@ export class SupabaseEntryStore {
|
|
|
473
473
|
if (isGlobalSchema(schema) && slug !== null)
|
|
474
474
|
throw entryErrors.noSlug(collection);
|
|
475
475
|
await this.assertSiblingSlug(collection, before.parentId, locale, slug, id);
|
|
476
|
+
const nodeFields = keepLocalizedShared(before.fields, shared, schema);
|
|
476
477
|
// The snapshot of what the row is about to hold, written under the guard BEFORE the live
|
|
477
478
|
// columns change (spec 2026-09-26 concurrency §3): a stale save leaves nothing behind.
|
|
478
|
-
const next = { ...before, fields:
|
|
479
|
+
const next = { ...before, fields: nodeFields, locales: { ...before.locales, [locale]: { ...row, fields: localized, slug } } };
|
|
479
480
|
const { created } = await this.recordVersion(next, locale, expected !== undefined);
|
|
480
|
-
await this.updateNode(id, { fields:
|
|
481
|
+
await this.updateNode(id, { fields: nodeFields });
|
|
481
482
|
await this.updateLocale(id, locale, { slug, fields: localized }, { by });
|
|
482
483
|
const record = await this.must(collection, id);
|
|
483
484
|
try {
|
|
@@ -535,7 +536,7 @@ export class SupabaseEntryStore {
|
|
|
535
536
|
const matched = await this.guardedUpdateLocale(id, locale, { fields: localized, draft_version_id: row.publishedVersionId }, { by, guard: expected !== undefined ? row.draftVersionId : undefined });
|
|
536
537
|
if (!matched)
|
|
537
538
|
throw await this.stale(collection, id, locale);
|
|
538
|
-
await this.updateNode(id, { fields: shared });
|
|
539
|
+
await this.updateNode(id, { fields: keepLocalizedShared(record.fields, shared, schema) });
|
|
539
540
|
if (row.draftVersionId && row.draftVersionId !== row.publishedVersionId) {
|
|
540
541
|
// The pointer moved first, so a failure here leaves a stray row, never a dangling pointer.
|
|
541
542
|
const gone = await this.db.from("entry_versions").delete().eq("id", row.draftVersionId);
|
|
@@ -598,6 +599,26 @@ export class SupabaseEntryStore {
|
|
|
598
599
|
await this.writeRefs(final);
|
|
599
600
|
return final;
|
|
600
601
|
}
|
|
602
|
+
async scanEntries() {
|
|
603
|
+
const res = await this.db.from("entries").select(SELECT).order("created_at", { ascending: true }).order("id", { ascending: true });
|
|
604
|
+
if (res.error)
|
|
605
|
+
this.fail(res.error.message);
|
|
606
|
+
const records = res.data.map(toRecord);
|
|
607
|
+
const ids = records.flatMap((r) => Object.values(r.locales).flatMap((row) => (row.status === "published" && row.publishedVersionId ? [row.publishedVersionId] : [])));
|
|
608
|
+
// In chunks: one in() over hundreds of ids would overflow the request URL.
|
|
609
|
+
const snaps = new Map();
|
|
610
|
+
for (let i = 0; i < ids.length; i += 100) {
|
|
611
|
+
for (const [id, fields] of await this.versionFields(ids.slice(i, i + 100)))
|
|
612
|
+
snaps.set(id, fields);
|
|
613
|
+
}
|
|
614
|
+
return records.map((record) => ({
|
|
615
|
+
record,
|
|
616
|
+
live: Object.fromEntries(Object.entries(record.locales).map(([l, row]) => [
|
|
617
|
+
l,
|
|
618
|
+
row.status !== "published" ? null : (row.publishedVersionId ? snaps.get(row.publishedVersionId) : undefined) ?? entryFieldsIn(record, l),
|
|
619
|
+
])),
|
|
620
|
+
}));
|
|
621
|
+
}
|
|
601
622
|
async listVersions(collection, id, locale) {
|
|
602
623
|
await this.must(collection, id);
|
|
603
624
|
const res = await this.db
|
|
@@ -621,7 +642,8 @@ export class SupabaseEntryStore {
|
|
|
621
642
|
assertFresh(row, expected);
|
|
622
643
|
if (versionId === row.publishedVersionId)
|
|
623
644
|
return this.discardDraft(collection, id, locale, by, expected);
|
|
624
|
-
const { shared, localized } = splitFields(version.fields, schema);
|
|
645
|
+
const { shared: split, localized } = splitFields(version.fields, schema);
|
|
646
|
+
const shared = keepLocalizedShared(entry.fields, split, schema);
|
|
625
647
|
// The snapshot first, under the guard, as in update.
|
|
626
648
|
const next = { ...entry, fields: shared, locales: { ...entry.locales, [locale]: { ...row, fields: localized } } };
|
|
627
649
|
await this.recordVersion(next, locale, expected !== undefined);
|
package/dist/supabase-store.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { SupabaseClient } from "@supabase/supabase-js";
|
|
2
|
-
import { type AddLocaleOptions, type CreatePageInput, type PageRecord, type PageStore, type PageStoreOptions, type PageVersion } from "./store.ts";
|
|
2
|
+
import { type AddLocaleOptions, type CreatePageInput, type PageRecord, type PageStore, type PageStoreOptions, type PageVersion, type PageScan } from "./store.ts";
|
|
3
3
|
import type { PageTree } from "./render.tsx";
|
|
4
4
|
import type { PathRow } from "./path-index.ts";
|
|
5
5
|
import { type DraftToken } from "./draft-token.ts";
|
|
@@ -58,6 +58,7 @@ export declare class SupabasePageStore implements PageStore {
|
|
|
58
58
|
private hardDelete;
|
|
59
59
|
createPage(input: CreatePageInput, by?: string): Promise<PageRecord>;
|
|
60
60
|
getPage(id: string): Promise<PageRecord | null>;
|
|
61
|
+
scanTrees(): Promise<PageScan[]>;
|
|
61
62
|
listPages(): Promise<PageRecord[]>;
|
|
62
63
|
/** The site's one warning per process (spec 2026-09-26 §5): fired by the first locate, never blocking. */
|
|
63
64
|
private schemaChecked;
|
package/dist/supabase-store.js
CHANGED
|
@@ -322,6 +322,33 @@ export class SupabasePageStore {
|
|
|
322
322
|
getPage(id) {
|
|
323
323
|
return this.row(id);
|
|
324
324
|
}
|
|
325
|
+
async scanTrees() {
|
|
326
|
+
const records = await this.listPages();
|
|
327
|
+
const ids = new Set();
|
|
328
|
+
for (const r of records)
|
|
329
|
+
for (const row of Object.values(r.locales)) {
|
|
330
|
+
if (row.draftVersionId)
|
|
331
|
+
ids.add(row.draftVersionId);
|
|
332
|
+
if (row.publishedVersionId)
|
|
333
|
+
ids.add(row.publishedVersionId);
|
|
334
|
+
}
|
|
335
|
+
// In chunks: one in() over hundreds of ids would overflow the request URL.
|
|
336
|
+
const trees = new Map();
|
|
337
|
+
const all = [...ids];
|
|
338
|
+
for (let i = 0; i < all.length; i += 100) {
|
|
339
|
+
const res = await this.db.from("page_versions").select("id, tree").in("id", all.slice(i, i + 100));
|
|
340
|
+
if (res.error)
|
|
341
|
+
this.fail(res.error.message);
|
|
342
|
+
for (const v of res.data)
|
|
343
|
+
trees.set(v.id, v.tree);
|
|
344
|
+
}
|
|
345
|
+
const tree = (id) => (id ? trees.get(id) ?? null : null);
|
|
346
|
+
return records.map((record) => ({
|
|
347
|
+
record,
|
|
348
|
+
draft: Object.fromEntries(Object.entries(record.locales).map(([l, row]) => [l, tree(row.draftVersionId)])),
|
|
349
|
+
live: Object.fromEntries(Object.entries(record.locales).map(([l, row]) => [l, tree(row.publishedVersionId)])),
|
|
350
|
+
}));
|
|
351
|
+
}
|
|
325
352
|
async listPages() {
|
|
326
353
|
const res = await this.db
|
|
327
354
|
.from("pages")
|
package/package.json
CHANGED