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.
Files changed (67) hide show
  1. package/README.md +48 -8
  2. package/dist/admin/client-ops.js +1 -0
  3. package/dist/admin/drift-copy.d.ts +19 -0
  4. package/dist/admin/drift-copy.js +89 -0
  5. package/dist/admin/drift.d.ts +76 -0
  6. package/dist/admin/drift.js +264 -0
  7. package/dist/admin/editor/EditorView.js +67 -15
  8. package/dist/admin/editor/InlineRichtext.js +3 -1
  9. package/dist/admin/editor/Outline.d.ts +7 -0
  10. package/dist/admin/editor/Outline.js +8 -5
  11. package/dist/admin/editor/PageInspector.d.ts +6 -1
  12. package/dist/admin/editor/PageInspector.js +4 -2
  13. package/dist/admin/editor/PageSettings.js +26 -16
  14. package/dist/admin/editor/UnknownNode.d.ts +7 -0
  15. package/dist/admin/editor/UnknownNode.js +8 -0
  16. package/dist/admin/field-paths.d.ts +3 -0
  17. package/dist/admin/field-paths.js +26 -0
  18. package/dist/admin/forms/OrphanFields.d.ts +8 -0
  19. package/dist/admin/forms/OrphanFields.js +11 -0
  20. package/dist/admin/forms/RefPicker.js +6 -2
  21. package/dist/admin/forms/RichtextElementCard.js +9 -3
  22. package/dist/admin/forms/RichtextWidget.js +3 -1
  23. package/dist/admin/forms/TagsField.js +1 -1
  24. package/dist/admin/forms/richtext-form-context.d.ts +8 -0
  25. package/dist/admin/forms/richtext-form-context.js +5 -0
  26. package/dist/admin/health.d.ts +23 -0
  27. package/dist/admin/health.js +42 -0
  28. package/dist/admin/ops-impl.js +40 -9
  29. package/dist/admin/ops.d.ts +13 -0
  30. package/dist/admin/shell/AddRedirectModal.js +1 -1
  31. package/dist/admin/shell/ContentHealthPage.d.ts +10 -0
  32. package/dist/admin/shell/ContentHealthPage.js +37 -0
  33. package/dist/admin/shell/EntriesList.js +4 -4
  34. package/dist/admin/shell/EntryForm.js +20 -3
  35. package/dist/admin/shell/ErrorPane.d.ts +4 -2
  36. package/dist/admin/shell/ErrorPane.js +5 -4
  37. package/dist/admin/shell/GlobalForm.js +22 -2
  38. package/dist/admin/shell/PagesList.js +3 -3
  39. package/dist/admin/shell/SettingsView.js +6 -2
  40. package/dist/admin/shell/health-list.d.ts +27 -0
  41. package/dist/admin/shell/health-list.js +47 -0
  42. package/dist/admin/shell/settings.d.ts +4 -0
  43. package/dist/admin/shell/settings.js +5 -2
  44. package/dist/admin/tree-ops.d.ts +4 -0
  45. package/dist/admin/tree-ops.js +15 -1
  46. package/dist/admin/ui/Combobox.d.ts +8 -2
  47. package/dist/admin/ui/Combobox.js +48 -16
  48. package/dist/admin/ui/ConfirmDialog.js +3 -3
  49. package/dist/admin/ui/DriftNotice.d.ts +6 -0
  50. package/dist/admin/ui/DriftNotice.js +10 -0
  51. package/dist/admin/ui/combo-box.d.ts +13 -0
  52. package/dist/admin/ui/combo-box.js +8 -0
  53. package/dist/admin/ui/confirm.d.ts +8 -0
  54. package/dist/admin/ui/confirm.js +17 -4
  55. package/dist/admin/ui/theme.js +29 -3
  56. package/dist/admin/validate.js +15 -47
  57. package/dist/entry-store.d.ts +13 -0
  58. package/dist/entry-store.js +26 -3
  59. package/dist/index.d.ts +2 -2
  60. package/dist/render.js +7 -2
  61. package/dist/store.d.ts +9 -0
  62. package/dist/store.js +8 -0
  63. package/dist/supabase-entry-store.d.ts +2 -1
  64. package/dist/supabase-entry-store.js +27 -5
  65. package/dist/supabase-store.d.ts +2 -1
  66. package/dist/supabase-store.js +27 -0
  67. package/package.json +1 -1
@@ -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>;
@@ -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: shared, locales: { ...before.locales, [locale]: { ...row, fields: localized, slug } } };
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: shared });
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);
@@ -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;
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "smoodly",
3
- "version": "0.0.28",
3
+ "version": "0.0.29",
4
4
  "description": "Open-source, code-first CMS for Next.js on Supabase: define a component once, it becomes a typed, editable building block.",
5
5
  "license": "MIT",
6
6
  "repository": {