smoodly 0.0.25 → 0.0.27

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 (59) hide show
  1. package/README.md +66 -2
  2. package/dist/admin/editor/EditorView.js +82 -18
  3. package/dist/admin/editor/autosave.d.ts +4 -1
  4. package/dist/admin/editor/autosave.js +24 -1
  5. package/dist/admin/editor/global-edit.d.ts +5 -1
  6. package/dist/admin/editor/global-edit.js +15 -5
  7. package/dist/admin/editor/useGlobalEdit.d.ts +1 -1
  8. package/dist/admin/editor/useGlobalEdit.js +1 -1
  9. package/dist/admin/next/create-admin.d.ts +4 -0
  10. package/dist/admin/next/create-admin.js +3 -1
  11. package/dist/admin/next/index.d.ts +1 -0
  12. package/dist/admin/ops-impl.js +70 -24
  13. package/dist/admin/ops.d.ts +22 -12
  14. package/dist/admin/shell/AdminApp.d.ts +3 -1
  15. package/dist/admin/shell/AdminApp.js +36 -3
  16. package/dist/admin/shell/EntriesList.js +5 -1
  17. package/dist/admin/shell/EntryForm.js +44 -7
  18. package/dist/admin/shell/GlobalForm.js +46 -7
  19. package/dist/admin/shell/GlobalList.js +6 -2
  20. package/dist/admin/shell/PagesList.js +5 -1
  21. package/dist/admin/shell/presence-context.d.ts +9 -0
  22. package/dist/admin/shell/presence-context.js +35 -0
  23. package/dist/admin/shell/presence-realtime.d.ts +3 -0
  24. package/dist/admin/shell/presence-realtime.js +32 -0
  25. package/dist/admin/shell/presence.d.ts +69 -0
  26. package/dist/admin/shell/presence.js +103 -0
  27. package/dist/admin/shell/stale.d.ts +28 -0
  28. package/dist/admin/shell/stale.js +17 -0
  29. package/dist/admin/ui/LockIcon.d.ts +4 -0
  30. package/dist/admin/ui/LockIcon.js +12 -0
  31. package/dist/admin/ui/PresenceLine.d.ts +6 -0
  32. package/dist/admin/ui/PresenceLine.js +11 -0
  33. package/dist/admin/ui/StaleBanner.d.ts +5 -0
  34. package/dist/admin/ui/StaleBanner.js +9 -0
  35. package/dist/admin/ui/primitives.d.ts +4 -1
  36. package/dist/admin/ui/primitives.js +5 -2
  37. package/dist/admin/ui/theme.js +4 -0
  38. package/dist/draft-token.d.ts +31 -0
  39. package/dist/draft-token.js +41 -0
  40. package/dist/entry-store.d.ts +11 -8
  41. package/dist/entry-store.js +24 -10
  42. package/dist/env.d.ts +9 -0
  43. package/dist/env.js +19 -0
  44. package/dist/index.d.ts +4 -2
  45. package/dist/index.js +2 -1
  46. package/dist/schema-version.d.ts +1 -1
  47. package/dist/schema-version.js +1 -1
  48. package/dist/sql-space.d.ts +6 -0
  49. package/dist/sql-space.js +41 -3
  50. package/dist/sql.js +16 -4
  51. package/dist/store.d.ts +11 -8
  52. package/dist/store.js +26 -8
  53. package/dist/supabase-entry-store.d.ts +15 -5
  54. package/dist/supabase-entry-store.js +87 -22
  55. package/dist/supabase-store.d.ts +15 -5
  56. package/dist/supabase-store.js +60 -20
  57. package/migrations/20260926130000_smoodly_v2.sql +26 -0
  58. package/migrations/20260926140000_smoodly_v3.sql +38 -0
  59. package/package.json +1 -1
@@ -12,10 +12,15 @@ import { SETTINGS_KEY, CSS_KEY } from "../global.js";
12
12
  import { withDefaults } from "../field-defaults.js";
13
13
  import { elementSchemas } from "../richtext-walk.js";
14
14
  import { SCHEMA_VERSION } from "../schema-version.js";
15
+ import { draftToken, isDraftToken, StaleDraftError } from "../draft-token.js";
15
16
  const ok = (data) => ({ ok: true, data });
16
17
  /** A history row without its content (spec 2026-09-13 history §6). */
17
18
  const summary = ({ id, savedAt }) => ({ id, savedAt });
18
19
  const err = (code, message, fields) => ({ ok: false, code, message, ...(fields ? { fields } : {}) });
20
+ /** The client's token is untrusted data: undefined skips, null is "no draft", the shape is the token, anything else is refused. */
21
+ const BAD_TOKEN = Symbol("bad token");
22
+ const tokenArg = (v) => v === undefined ? undefined : v === null ? null : isDraftToken(v) ? v : BAD_TOKEN;
23
+ const badToken = () => err("validation", "smoodly: malformed draft token.");
19
24
  /** Own-property, non-array plain object — same shape guard the tree checks
20
25
  * below and `runAdminOp`'s envelope check both lean on. */
21
26
  const isPlainObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
@@ -42,6 +47,9 @@ function isPageTree(tree) {
42
47
  }
43
48
  /** Store throws are stringly-typed; classify the known guards, default to internal. */
44
49
  const classify = (e) => {
50
+ // The guard's refusal (spec 2026-09-26 concurrency §2): typed, so it never depends on message matching.
51
+ if (e instanceof StaleDraftError)
52
+ return { ok: false, code: "conflict", stale: true, by: e.by, at: e.at, message: e.message };
45
53
  const message = e instanceof Error ? e.message : String(e);
46
54
  if (message.includes("already exists in locale"))
47
55
  return err("conflict", message);
@@ -324,20 +332,28 @@ export function createAdminOps(deps) {
324
332
  expireTree();
325
333
  return ok(created);
326
334
  }),
327
- saveDraft: (id, locale, tree) => guard(async () => {
335
+ saveDraft: (id, locale, tree, expected) => guard(async () => {
328
336
  const unsupported = supportedLocale(locale);
329
337
  if (unsupported)
330
338
  return unsupported;
331
339
  if (!isPageTree(tree))
332
340
  return err("validation", "smoodly: malformed page tree.");
341
+ const token = tokenArg(expected);
342
+ if (token === BAD_TOKEN)
343
+ return badToken();
333
344
  if (!(await pages.getPage(id)))
334
345
  return err("not_found", `smoodly: no page "${id}".`);
335
- return ok({ versionId: (await pages.saveDraft(id, locale, tree, by)).id });
346
+ const version = await pages.saveDraft(id, locale, tree, by, token);
347
+ const after = await pages.getPage(id);
348
+ return ok({ versionId: version.id, draft: after?.locales[locale] ? draftToken(after.locales[locale]) : null });
336
349
  }),
337
- publish: (id, locale) => guard(async () => {
350
+ publish: (id, locale, expected) => guard(async () => {
338
351
  const unsupported = supportedLocale(locale);
339
352
  if (unsupported)
340
353
  return unsupported;
354
+ const token = tokenArg(expected);
355
+ if (token === BAD_TOKEN)
356
+ return badToken();
341
357
  const record = await pages.getPage(id);
342
358
  if (!record)
343
359
  return err("not_found", `smoodly: no page "${id}".`);
@@ -373,7 +389,7 @@ export function createAdminOps(deps) {
373
389
  if (fieldErrors.length > 0) {
374
390
  return err("validation", `smoodly: cannot publish "${row.slug}" — fix the listed fields first.`, fieldErrors);
375
391
  }
376
- const published = await pages.publish(id, locale, by);
392
+ const published = await pages.publish(id, locale, by, token);
377
393
  // The parent's cached unit lists its published children.
378
394
  expirePages([published.id, published.parentId]);
379
395
  expireTree();
@@ -398,10 +414,13 @@ export function createAdminOps(deps) {
398
414
  expireTree();
399
415
  return ok(off);
400
416
  }),
401
- discardDraft: (id, locale) => guard(async () => {
417
+ discardDraft: (id, locale, expected) => guard(async () => {
402
418
  const unsupported = supportedLocale(locale);
403
419
  if (unsupported)
404
420
  return unsupported;
421
+ const token = tokenArg(expected);
422
+ if (token === BAD_TOKEN)
423
+ return badToken();
405
424
  const record = await pages.getPage(id);
406
425
  if (!record)
407
426
  return err("not_found", `smoodly: no page "${id}".`);
@@ -411,7 +430,7 @@ export function createAdminOps(deps) {
411
430
  if (!row.publishedVersionId) {
412
431
  return err("blocked", `smoodly: "${nameOf(record)}" has nothing published in ${locale} — there is no version to restore.`);
413
432
  }
414
- const restored = await pages.discardDraft(id, locale, by);
433
+ const restored = await pages.discardDraft(id, locale, by, token);
415
434
  expirePages([restored.id]); // the draft-mode render of this page changed
416
435
  return ok(restored);
417
436
  }),
@@ -423,10 +442,13 @@ export function createAdminOps(deps) {
423
442
  return err("not_found", `smoodly: no page "${id}".`);
424
443
  return ok((await pages.listVersions(id, locale)).map(summary));
425
444
  }),
426
- restore: (id, locale, versionId) => guard(async () => {
445
+ restore: (id, locale, versionId, expected) => guard(async () => {
427
446
  const unsupported = supportedLocale(locale);
428
447
  if (unsupported)
429
448
  return unsupported;
449
+ const token = tokenArg(expected);
450
+ if (token === BAD_TOKEN)
451
+ return badToken();
430
452
  const record = await pages.getPage(id);
431
453
  if (!record)
432
454
  return err("not_found", `smoodly: no page "${id}".`);
@@ -438,7 +460,7 @@ export function createAdminOps(deps) {
438
460
  if (!version || version.pageId !== id || version.locale !== locale) {
439
461
  return err("not_found", `smoodly: no version "${String(versionId)}" of this page in ${locale}.`);
440
462
  }
441
- const restored = await pages.restore(id, locale, versionId, by);
463
+ const restored = await pages.restore(id, locale, versionId, by, token);
442
464
  expirePages([restored.id]); // the draft-mode render of this page changed
443
465
  return ok(restored);
444
466
  }),
@@ -598,22 +620,28 @@ export function createAdminOps(deps) {
598
620
  expireCollection(collection);
599
621
  return ok(created);
600
622
  }),
601
- save: (collection, id, locale, fields) => guard(async () => {
623
+ save: (collection, id, locale, fields, expected) => guard(async () => {
602
624
  const unsupported = supportedLocale(locale);
603
625
  if (unsupported)
604
626
  return unsupported;
627
+ const token = tokenArg(expected);
628
+ if (token === BAD_TOKEN)
629
+ return badToken();
605
630
  const descriptors = collectionFields(collection);
606
631
  if (!descriptors)
607
632
  return err("not_found", `smoodly: no collection named "${collection}".`);
608
- const entry = await entries.update(collection, id, locale, fields, by);
633
+ const entry = await entries.update(collection, id, locale, fields, by, token);
609
634
  await fanOut(id);
610
635
  expireCollection(collection);
611
636
  return ok(entry);
612
637
  }),
613
- setStatus: (collection, id, locale, status) => guard(async () => {
638
+ setStatus: (collection, id, locale, status, expected) => guard(async () => {
614
639
  const unsupported = supportedLocale(locale);
615
640
  if (unsupported)
616
641
  return unsupported;
642
+ const token = tokenArg(expected);
643
+ if (token === BAD_TOKEN)
644
+ return badToken();
617
645
  if (status === "published") {
618
646
  // A draft may be incomplete; what goes live may not (spec 2026-09-14 §2).
619
647
  const descriptors = collectionFields(collection);
@@ -628,16 +656,19 @@ export function createAdminOps(deps) {
628
656
  if (fieldErrors.length > 0)
629
657
  return err("validation", "Some fields need attention.", fieldErrors);
630
658
  }
631
- const entry = await entries.setStatus(collection, id, locale, status, by);
659
+ const entry = await entries.setStatus(collection, id, locale, status, by, token);
632
660
  await fanOut(id);
633
661
  expireCollection(collection);
634
662
  return ok(entry);
635
663
  }),
636
- discardDraft: (collection, id, locale) => guard(async () => {
664
+ discardDraft: (collection, id, locale, expected) => guard(async () => {
637
665
  const unsupported = supportedLocale(locale);
638
666
  if (unsupported)
639
667
  return unsupported;
640
- const restored = await entries.discardDraft(collection, id, locale, by);
668
+ const token = tokenArg(expected);
669
+ if (token === BAD_TOKEN)
670
+ return badToken();
671
+ const restored = await entries.discardDraft(collection, id, locale, by, token);
641
672
  await fanOut(id);
642
673
  expireCollection(collection);
643
674
  return ok(restored);
@@ -657,15 +688,18 @@ export function createAdminOps(deps) {
657
688
  }
658
689
  return ok({ ...summary(version), fields: version.fields });
659
690
  }),
660
- restore: (collection, id, locale, versionId) => guard(async () => {
691
+ restore: (collection, id, locale, versionId, expected) => guard(async () => {
661
692
  const unsupported = supportedLocale(locale);
662
693
  if (unsupported)
663
694
  return unsupported;
695
+ const token = tokenArg(expected);
696
+ if (token === BAD_TOKEN)
697
+ return badToken();
664
698
  const version = typeof versionId === "string" ? await entries.getVersion(collection, versionId) : null;
665
699
  if (!version || version.entryId !== id || version.locale !== locale) {
666
700
  return err("not_found", `smoodly: no version "${String(versionId)}" of entry "${id}" in "${collection}", locale ${locale}.`);
667
701
  }
668
- const restored = await entries.restore(collection, id, locale, versionId, by);
702
+ const restored = await entries.restore(collection, id, locale, versionId, by, token);
669
703
  await fanOut(id);
670
704
  expireCollection(collection);
671
705
  return ok(restored);
@@ -720,25 +754,31 @@ export function createAdminOps(deps) {
720
754
  const usage = await entries.referencesTo(name, record.id);
721
755
  return ok({ name, record, locale, fields: entryFieldsIn(record, locale), usage });
722
756
  }),
723
- save: (name, locale, fields) => guard(async () => {
757
+ save: (name, locale, fields, expected) => guard(async () => {
724
758
  const unsupported = supportedLocale(locale);
725
759
  if (unsupported)
726
760
  return unsupported;
761
+ const token = tokenArg(expected);
762
+ if (token === BAD_TOKEN)
763
+ return badToken();
727
764
  const item = globalItem(name);
728
765
  if (!item)
729
766
  return err("not_found", `smoodly: no global item named "${name}".`);
730
767
  const record = await globalRecord(name);
731
768
  if (!record)
732
769
  return err("not_found", `smoodly: global item "${name}" is not materialized — open Globals first.`);
733
- const saved = await entries.update(name, record.id, locale, fields, by);
770
+ const saved = await entries.update(name, record.id, locale, fields, by, token);
734
771
  // fanOut expires `global:<name>` itself — this row IS the item's.
735
772
  await fanOut(record.id);
736
773
  return ok(saved);
737
774
  }),
738
- setStatus: (name, locale, status) => guard(async () => {
775
+ setStatus: (name, locale, status, expected) => guard(async () => {
739
776
  const unsupported = supportedLocale(locale);
740
777
  if (unsupported)
741
778
  return unsupported;
779
+ const token = tokenArg(expected);
780
+ if (token === BAD_TOKEN)
781
+ return badToken();
742
782
  const item = globalItem(name);
743
783
  if (!item)
744
784
  return err("not_found", `smoodly: no global item named "${name}".`);
@@ -757,21 +797,24 @@ export function createAdminOps(deps) {
757
797
  if (fieldErrors.length > 0)
758
798
  return err("validation", "Some fields need attention.", fieldErrors);
759
799
  }
760
- const changed = await entries.setStatus(name, record.id, locale, status, by);
800
+ const changed = await entries.setStatus(name, record.id, locale, status, by, token);
761
801
  // see save: the fan-out carries the item's own tag.
762
802
  await fanOut(record.id);
763
803
  return ok(changed);
764
804
  }),
765
- discardDraft: (name, locale) => guard(async () => {
805
+ discardDraft: (name, locale, expected) => guard(async () => {
766
806
  const unsupported = supportedLocale(locale);
767
807
  if (unsupported)
768
808
  return unsupported;
809
+ const token = tokenArg(expected);
810
+ if (token === BAD_TOKEN)
811
+ return badToken();
769
812
  if (!globalItem(name))
770
813
  return err("not_found", `smoodly: no global item named "${name}".`);
771
814
  const record = await globalRecord(name);
772
815
  if (!record)
773
816
  return err("not_found", `smoodly: global item "${name}" is not materialized — open Globals first.`);
774
- const restored = await entries.discardDraft(name, record.id, locale, by);
817
+ const restored = await entries.discardDraft(name, record.id, locale, by, token);
775
818
  await fanOut(record.id);
776
819
  return ok(restored);
777
820
  }),
@@ -798,10 +841,13 @@ export function createAdminOps(deps) {
798
841
  }
799
842
  return ok({ ...summary(version), fields: version.fields });
800
843
  }),
801
- restore: (name, locale, versionId) => guard(async () => {
844
+ restore: (name, locale, versionId, expected) => guard(async () => {
802
845
  const unsupported = supportedLocale(locale);
803
846
  if (unsupported)
804
847
  return unsupported;
848
+ const token = tokenArg(expected);
849
+ if (token === BAD_TOKEN)
850
+ return badToken();
805
851
  if (!globalItem(name))
806
852
  return err("not_found", `smoodly: no global item named "${name}".`);
807
853
  const record = await globalRecord(name);
@@ -811,7 +857,7 @@ export function createAdminOps(deps) {
811
857
  if (!version || version.entryId !== record.id || version.locale !== locale) {
812
858
  return err("not_found", `smoodly: no version "${String(versionId)}" of global item "${name}" in ${locale}.`);
813
859
  }
814
- const restored = await entries.restore(name, record.id, locale, versionId, by);
860
+ const restored = await entries.restore(name, record.id, locale, versionId, by, token);
815
861
  await fanOut(record.id);
816
862
  return ok(restored);
817
863
  }),
@@ -6,11 +6,17 @@ import type { AssetPage, AssetQuery, FinishInput, UploadTicket } from "../asset-
6
6
  import type { InviteInput, InviteResult, MemberRecord } from "../member-store.ts";
7
7
  import type { MemberRole } from "../permissions.ts";
8
8
  import type { FieldError } from "./validate.ts";
9
+ import type { DraftToken } from "../draft-token.ts";
9
10
  export type OpError = {
10
11
  ok: false;
11
12
  code: "not_found" | "conflict" | "blocked" | "validation" | "internal" | "unauthorized";
12
13
  message: string;
13
14
  fields?: FieldError[];
15
+ /** A `conflict` from the token guard (spec 2026-09-26 concurrency §2): the draft moved since the client loaded it. */
16
+ stale?: true;
17
+ /** Who wrote it last and when — the freeze notice's two facts. */
18
+ by?: string | null;
19
+ at?: number;
14
20
  };
15
21
  export type OpResult<T> = {
16
22
  ok: true;
@@ -110,14 +116,16 @@ export type AdminOps = {
110
116
  /** not_found only when the page is missing; an absent locale comes back with `draft: null`. */
111
117
  get(id: string, locale: string): Promise<OpResult<PageGetResult>>;
112
118
  create(input: PageCreateInput): Promise<OpResult<PageRecord>>;
113
- saveDraft(id: string, locale: string, tree: PageTree): Promise<OpResult<{
119
+ /** `expected` is the token the client last saw (spec 2026-09-26 concurrency §1); `undefined` skips the check — the admin always sends one. */
120
+ saveDraft(id: string, locale: string, tree: PageTree, expected?: DraftToken | null): Promise<OpResult<{
114
121
  versionId: string;
122
+ draft: DraftToken | null;
115
123
  }>>;
116
- publish(id: string, locale: string): Promise<OpResult<PageRecord>>;
124
+ publish(id: string, locale: string, expected?: DraftToken | null): Promise<OpResult<PageRecord>>;
117
125
  /** Takes the locale off the site; status draft, pointers kept. Never the site root or a fixed node (spec 2026-09-13 publish flow §5). */
118
126
  unpublish(id: string, locale: string): Promise<OpResult<PageRecord>>;
119
127
  /** The draft back to the published version (§4); `blocked` when nothing is published in the locale. */
120
- discardDraft(id: string, locale: string): Promise<OpResult<PageRecord>>;
128
+ discardDraft(id: string, locale: string, expected?: DraftToken | null): Promise<OpResult<PageRecord>>;
121
129
  delete(id: string): Promise<OpResult<null>>;
122
130
  rename(id: string, patch: {
123
131
  locale: string;
@@ -139,7 +147,7 @@ export type AdminOps = {
139
147
  /** The locale's versions newest first, without their trees. */
140
148
  listVersions(id: string, locale: string): Promise<OpResult<VersionSummary[]>>;
141
149
  /** The version's tree becomes the draft (spec 2026-09-13 history §6); `not_found` for a version of another page or locale, `blocked` on a folder. */
142
- restore(id: string, locale: string, versionId: string): Promise<OpResult<PageRecord>>;
150
+ restore(id: string, locale: string, versionId: string, expected?: DraftToken | null): Promise<OpResult<PageRecord>>;
143
151
  };
144
152
  entries: {
145
153
  list(collection: string): Promise<OpResult<EntryRecord[]>>;
@@ -150,10 +158,11 @@ export type AdminOps = {
150
158
  /** not_found only when the entry is missing; an absent locale comes back with `fields: null`. */
151
159
  get(collection: string, id: string, locale: string): Promise<OpResult<EntryGetResult>>;
152
160
  create(collection: string, input: EntryCreateInput): Promise<OpResult<EntryRecord>>;
153
- save(collection: string, id: string, locale: string, fields: Record<string, unknown>): Promise<OpResult<EntryRecord>>;
154
- setStatus(collection: string, id: string, locale: string, status: "draft" | "published"): Promise<OpResult<EntryRecord>>;
161
+ /** `expected` as on pages: the token the client last saw; `undefined` skips the check. */
162
+ save(collection: string, id: string, locale: string, fields: Record<string, unknown>, expected?: DraftToken | null): Promise<OpResult<EntryRecord>>;
163
+ setStatus(collection: string, id: string, locale: string, status: "draft" | "published", expected?: DraftToken | null): Promise<OpResult<EntryRecord>>;
155
164
  /** The live row back to the published snapshot (§4); `blocked` when the locale has no published version. */
156
- discardDraft(collection: string, id: string, locale: string): Promise<OpResult<EntryRecord>>;
165
+ discardDraft(collection: string, id: string, locale: string, expected?: DraftToken | null): Promise<OpResult<EntryRecord>>;
157
166
  delete(collection: string, id: string): Promise<OpResult<null>>;
158
167
  move(collection: string, id: string, to: {
159
168
  parentId: string | null;
@@ -170,19 +179,20 @@ export type AdminOps = {
170
179
  /** One snapshot's fields; `not_found` unless it belongs to `id`. */
171
180
  getVersion(collection: string, id: string, versionId: string): Promise<OpResult<EntryVersionResult>>;
172
181
  /** The snapshot becomes the draft (§6); `not_found` for a foreign version. Fans out like a save. */
173
- restore(collection: string, id: string, locale: string, versionId: string): Promise<OpResult<EntryRecord>>;
182
+ restore(collection: string, id: string, locale: string, versionId: string, expected?: DraftToken | null): Promise<OpResult<EntryRecord>>;
174
183
  };
175
184
  globals: {
176
185
  /** Materializes every registered item in every supported locale, then lists them in registry order. */
177
186
  list(): Promise<OpResult<GlobalListItem[]>>;
178
187
  get(name: string, locale: string): Promise<OpResult<GlobalGetResult>>;
179
- save(name: string, locale: string, fields: Record<string, unknown>): Promise<OpResult<EntryRecord>>;
180
- setStatus(name: string, locale: string, status: "draft" | "published"): Promise<OpResult<EntryRecord>>;
188
+ /** `expected` as on pages: the token the client last saw; `undefined` skips the check. */
189
+ save(name: string, locale: string, fields: Record<string, unknown>, expected?: DraftToken | null): Promise<OpResult<EntryRecord>>;
190
+ setStatus(name: string, locale: string, status: "draft" | "published", expected?: DraftToken | null): Promise<OpResult<EntryRecord>>;
181
191
  /** The item's live row back to its published snapshot (§4); `blocked` before the first publish. */
182
- discardDraft(name: string, locale: string): Promise<OpResult<EntryRecord>>;
192
+ discardDraft(name: string, locale: string, expected?: DraftToken | null): Promise<OpResult<EntryRecord>>;
183
193
  listVersions(name: string, locale: string): Promise<OpResult<VersionSummary[]>>;
184
194
  getVersion(name: string, versionId: string): Promise<OpResult<EntryVersionResult>>;
185
- restore(name: string, locale: string, versionId: string): Promise<OpResult<EntryRecord>>;
195
+ restore(name: string, locale: string, versionId: string, expected?: DraftToken | null): Promise<OpResult<EntryRecord>>;
186
196
  };
187
197
  assets: {
188
198
  /** Ready assets, newest first, paged by cursor; `kind` and `search` filter. */
@@ -1,8 +1,10 @@
1
1
  import type { AdminOpAction } from "../ops.ts";
2
2
  import type { AdminRegistry } from "../serialize.ts";
3
3
  import { type AuthConfig } from "./session.ts";
4
- export declare function AdminApp({ registry, op, auth }: {
4
+ import type { ContentConfig } from "../../env.ts";
5
+ export declare function AdminApp({ registry, op, auth, content }: {
5
6
  registry: AdminRegistry;
6
7
  op: AdminOpAction;
7
8
  auth: AuthConfig;
9
+ content?: ContentConfig;
8
10
  }): import("react").JSX.Element;
@@ -12,7 +12,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
12
12
  // (shell/IconRail.tsx), then the main column whose views own their own
13
13
  // 48px top bar (the editor adds the third, its inspector). The shell is
14
14
  // viewport-height; every column scrolls inside itself.
15
- import { useEffect, useState } from "react";
15
+ import { useEffect, useRef, useState } from "react";
16
16
  import { makeClientOps } from "../client-ops.js";
17
17
  import { PagesList } from "./PagesList.js";
18
18
  import { EntriesList } from "./EntriesList.js";
@@ -37,7 +37,10 @@ import { ElementsProvider } from "../forms/elements-context.js";
37
37
  import { TimeZoneProvider } from "../forms/time-zone-context.js";
38
38
  import { SchemaNotice } from "./SchemaNotice.js";
39
39
  import { schemaBehind } from "../../schema-version.js";
40
- export function AdminApp({ registry, op, auth }) {
40
+ import { createPresence, followTokenRefresh } from "./presence.js";
41
+ import { connectRealtime } from "./presence-realtime.js";
42
+ import { PresenceProvider } from "./presence-context.js";
43
+ export function AdminApp({ registry, op, auth, content }) {
41
44
  const session = useAdminSession(auth);
42
45
  const [ops] = useState(() => makeClientOps(op, session.token));
43
46
  const route = useRoute(registry.adminPath);
@@ -78,6 +81,36 @@ export function AdminApp({ registry, op, auth }) {
78
81
  return () => { live = false; };
79
82
  }, [signedIn, ops]);
80
83
  const userId = session.state.status === "signed_in" ? session.state.session.user.id : "";
84
+ // Presence (spec 2026-09-26 concurrency §5; gated 2026-09-26 security pass §1):
85
+ // one channel per signed-in session, the content project's public config as the
86
+ // gateway and the SESSION token on the socket; absent config = presence off.
87
+ // The token getter is a new closure every render — read it through a ref so
88
+ // the effect keys on the session, not on the render.
89
+ const [presence, setPresence] = useState(null);
90
+ const tokenRef = useRef(session.token);
91
+ tokenRef.current = session.token;
92
+ const authClient = session.client;
93
+ const contentUrl = content?.url, contentKey = content?.key, contentSpace = content?.spaceId;
94
+ useEffect(() => {
95
+ if (!signedIn || !contentUrl || !contentKey || !contentSpace || !email) {
96
+ setPresence(null);
97
+ return;
98
+ }
99
+ const config = { url: contentUrl, key: contentKey, spaceId: contentSpace };
100
+ const p = createPresence({
101
+ email,
102
+ connect: () => {
103
+ const token = tokenRef.current();
104
+ if (!token)
105
+ throw new Error("smoodly: no session token for presence");
106
+ return connectRealtime(config, email, token);
107
+ },
108
+ });
109
+ p.join();
110
+ setPresence(p);
111
+ const stop = followTokenRefresh(authClient.auth, p);
112
+ return () => { stop(); p.dispose(); setPresence(null); };
113
+ }, [signedIn, contentUrl, contentKey, contentSpace, email, authClient]);
81
114
  if (session.state.status === "loading") {
82
115
  return (_jsx("div", { style: { height: "100vh", display: "grid", placeItems: "center", color: "var(--text-muted)" }, children: "Loading\u2026" }));
83
116
  }
@@ -111,5 +144,5 @@ export function AdminApp({ registry, op, auth }) {
111
144
  main = _jsx(MediaScreen, {});
112
145
  else
113
146
  main = _jsx(ErrorPane, { message: "Not found." });
114
- return (_jsx(AdminPathProvider, { value: registry.adminPath, children: _jsx(ElementsProvider, { elements: registry.elements, children: _jsx(TimeZoneProvider, { zone: registry.timeZone, children: _jsxs(AssetsProvider, { ops: ops, media: registry.media, children: [_jsxs("div", { className: "sm-shell", children: [_jsx(IconRail, { registry: registry, active: head, email: email, settings: role !== null && can(role, "settings"), onSignOut: () => session.client.auth.signOut() }), _jsx("main", { className: "sm-main", children: main }, `${head}/${tail ?? ""}`)] }), _jsx(ConfirmHost, {})] }) }) }) }));
147
+ return (_jsx(AdminPathProvider, { value: registry.adminPath, children: _jsx(PresenceProvider, { value: presence, children: _jsx(ElementsProvider, { elements: registry.elements, children: _jsx(TimeZoneProvider, { zone: registry.timeZone, children: _jsxs(AssetsProvider, { ops: ops, media: registry.media, children: [_jsxs("div", { className: "sm-shell", children: [_jsx(IconRail, { registry: registry, active: head, email: email, settings: role !== null && can(role, "settings"), onSignOut: () => session.client.auth.signOut() }), _jsx("main", { className: "sm-main", children: main }, `${head}/${tail ?? ""}`)] }), _jsx(ConfirmHost, {})] }) }) }) }) }));
115
148
  }
@@ -17,12 +17,16 @@ import { navigate } from "./router.js";
17
17
  import { confirm } from "../ui/confirm.js";
18
18
  import { ErrorPane } from "./ErrorPane.js";
19
19
  import { rememberLocale, rememberedLocale } from "./locale-memory.js";
20
+ import { usePresenceState } from "./presence-context.js";
21
+ import { editorsOf } from "./presence.js";
22
+ import { LockIcon } from "../ui/LockIcon.js";
20
23
  export function EntriesList({ ops, registry, collection }) {
21
24
  const adminHref = useAdminHref();
22
25
  const meta = registry.collections.find((c) => c.name === collection);
23
26
  const [records, setRecords] = useState(null);
24
27
  const [error, setError] = useState(null);
25
28
  const [locale, setLocale] = useState(() => rememberedLocale(registry.locales));
29
+ const { others } = usePresenceState();
26
30
  const multilingual = registry.locales.supported.length > 1;
27
31
  const pickLocale = (next) => {
28
32
  setLocale(next);
@@ -113,7 +117,7 @@ export function EntriesList({ ops, registry, collection }) {
113
117
  key: row.id,
114
118
  onClick: () => (row.present ? open(row.id) : void addLocale(row)),
115
119
  cells: [
116
- _jsx("span", { className: "sm-cell sm-cell--title", style: dim, children: row.title }, "t"),
120
+ _jsxs("span", { className: "sm-cell sm-cell--title", style: dim, children: [row.title, _jsx(LockIcon, { names: editorsOf(others, { kind: "entry", id: row.id, locale }) })] }, "t"),
117
121
  row.present
118
122
  ? _jsx("span", { className: "sm-cell sm-cell--muted", children: relativeTime(row.updatedAt) }, "u")
119
123
  : _jsxs("span", { className: "sm-cell sm-cell--muted", children: ["not in ", locale, " \u2014 click to add"] }, "u"),
@@ -37,6 +37,12 @@ import { localeFromUrl, writeLocaleToUrl } from "../editor/locale-url.js";
37
37
  import { navigate, useLeaveGuard } from "./router.js";
38
38
  import { LIVE_URL_CONFIRM, liveUrlChanges } from "./live-url.js";
39
39
  import { confirm, DISCARD_CONFIRM } from "../ui/confirm.js";
40
+ import { draftToken } from "../../draft-token.js";
41
+ import { isStale, staleInfoOf } from "./stale.js";
42
+ import { StaleBanner } from "../ui/StaleBanner.js";
43
+ import { usePresence, usePresenceState, useSavedElsewhere, useTrackPresence } from "./presence-context.js";
44
+ import { editorsOf } from "./presence.js";
45
+ import { PresenceLine } from "../ui/PresenceLine.js";
40
46
  /** A stable empty map for the hooks that run before the "no such collection" return. */
41
47
  const NO_FIELDS = {};
42
48
  export function EntryForm({ ops, registry, collection, id }) {
@@ -61,6 +67,7 @@ export function EntryForm({ ops, registry, collection, id }) {
61
67
  const [published, setPublished] = useState(false);
62
68
  const [history, setHistory] = useState(null);
63
69
  const [snapshot, setSnapshot] = useState(null);
70
+ const [stale, setStale] = useState(null);
64
71
  const formRef = useRef(null);
65
72
  const fieldsRef = useRef(fields);
66
73
  fieldsRef.current = fields;
@@ -69,7 +76,14 @@ export function EntryForm({ ops, registry, collection, id }) {
69
76
  /** The slug the editor already confirmed as a live-URL change, so the next autosave does not ask again. */
70
77
  const confirmedSlug = useRef(null);
71
78
  // The page editor's controller: debounced, retrying, flushable (spec 2026-09-14 §3).
72
- const { saveState, mark, flush } = useDraftAutosave({
79
+ /** The token of the row on screen — what every guarded op sends (spec 2026-09-26 concurrency §1). */
80
+ const token = () => { const row = recordRef.current?.locales[locale]; return row ? draftToken(row) : null; };
81
+ // Presence (spec 2026-09-26 concurrency §5): this tab on this row, the others on it, every ack announced.
82
+ const target = { kind: "entry", id, locale };
83
+ useTrackPresence(target);
84
+ const { others } = usePresenceState();
85
+ const presence = usePresence();
86
+ const { saveState, mark, flush, reset } = useDraftAutosave({
73
87
  read: () => fieldsRef.current,
74
88
  persist: async (value) => {
75
89
  // A published entry's slug is a live URL: one confirm at the save, Cancel restores it (members management §5).
@@ -86,9 +100,13 @@ export function EntryForm({ ops, registry, collection, id }) {
86
100
  setFields((f) => ({ ...(f ?? {}), slug: stored }));
87
101
  }
88
102
  }
89
- const r = await ops.entries.save(collection, id, locale, value);
90
- if (r.ok)
103
+ const r = await ops.entries.save(collection, id, locale, value, token());
104
+ if (r.ok) {
91
105
  setRecord(r.data);
106
+ presence?.announce(target, r.data.locales[locale]?.draftSavedAt ?? null);
107
+ }
108
+ else if (isStale(r))
109
+ setStale(staleInfoOf(r));
92
110
  return r;
93
111
  },
94
112
  onSaved: () => setDirty(false),
@@ -113,6 +131,7 @@ export function EntryForm({ ops, registry, collection, id }) {
113
131
  setFields(null);
114
132
  setFieldErrors({});
115
133
  setDirty(false);
134
+ setStale(null);
116
135
  setTouched(false);
117
136
  ops.entries.get(collection, id, locale).then((r) => {
118
137
  if (cancelled)
@@ -151,6 +170,10 @@ export function EntryForm({ ops, registry, collection, id }) {
151
170
  }, [fields, hasFieldErrors, meta, elementDescs]);
152
171
  /** An op's failure onto the screen: amber with the fields for a validation result, red otherwise. */
153
172
  const fail = (r) => {
173
+ if (isStale(r)) {
174
+ setStale(staleInfoOf(r));
175
+ return;
176
+ }
154
177
  if (r.code === "validation" && r.fields && r.fields.length > 0) {
155
178
  setFieldErrors(Object.fromEntries(r.fields.map((f) => [f.field, f.message])));
156
179
  setNotice({ tone: "warn", text: r.message, fields: r.fields });
@@ -158,6 +181,17 @@ export function EntryForm({ ops, registry, collection, id }) {
158
181
  else
159
182
  setNotice({ tone: "error", text: r.message });
160
183
  };
184
+ /** Load their version (spec 2026-09-26 concurrency §4): drop the frozen state and re-get through the ops seam. */
185
+ const loadTheirs = () => { setStale(null); reset(); setReloadKey((k) => k + 1); };
186
+ // Someone else saved this row (§5): clean → their version silently; unsaved edits → the freeze at once.
187
+ useSavedElsewhere(target, (event) => {
188
+ if (stale)
189
+ return;
190
+ if (dirty || saveState !== "saved")
191
+ setStale({ by: event.email, at: Date.now() });
192
+ else
193
+ setReloadKey((k) => k + 1);
194
+ });
161
195
  /** Publish what is on screen: the pending edit is flushed first, and a failed flush cancels the publish. */
162
196
  const publish = async () => {
163
197
  setNotice(null);
@@ -169,7 +203,7 @@ export function EntryForm({ ops, registry, collection, id }) {
169
203
  }
170
204
  let r;
171
205
  try {
172
- r = await ops.entries.setStatus(collection, id, locale, "published");
206
+ r = await ops.entries.setStatus(collection, id, locale, "published", token());
173
207
  }
174
208
  catch {
175
209
  r = { ok: false, code: "internal", message: "Couldn't reach the server." };
@@ -180,6 +214,7 @@ export function EntryForm({ ops, registry, collection, id }) {
180
214
  return;
181
215
  }
182
216
  setRecord(r.data);
217
+ presence?.announce(target, r.data.locales[locale]?.draftSavedAt ?? null);
183
218
  setFieldErrors({});
184
219
  setPublished(true);
185
220
  setTimeout(() => setPublished(false), 1600);
@@ -213,7 +248,7 @@ export function EntryForm({ ops, registry, collection, id }) {
213
248
  }
214
249
  let r;
215
250
  try {
216
- r = await ops.entries.discardDraft(collection, id, locale);
251
+ r = await ops.entries.discardDraft(collection, id, locale, token());
217
252
  }
218
253
  catch {
219
254
  r = { ok: false, code: "internal", message: "Couldn't reach the server." };
@@ -224,6 +259,7 @@ export function EntryForm({ ops, registry, collection, id }) {
224
259
  return;
225
260
  }
226
261
  setRecord(r.data);
262
+ presence?.announce(target, r.data.locales[locale]?.draftSavedAt ?? null);
227
263
  setReloadKey((k) => k + 1);
228
264
  setNotice({ tone: "ok", text: "Changes discarded." });
229
265
  };
@@ -251,12 +287,13 @@ export function EntryForm({ ops, registry, collection, id }) {
251
287
  fail(r);
252
288
  };
253
289
  const restore = async (versionId) => {
254
- const r = await ops.entries.restore(collection, id, locale, versionId);
290
+ const r = await ops.entries.restore(collection, id, locale, versionId, token());
255
291
  if (!r.ok) {
256
292
  fail(r);
257
293
  return false;
258
294
  }
259
295
  setRecord(r.data);
296
+ presence?.announce(target, r.data.locales[locale]?.draftSavedAt ?? null);
260
297
  setReloadKey((k) => k + 1);
261
298
  setNotice({ tone: "ok", text: "Version restored." });
262
299
  return true;
@@ -374,6 +411,6 @@ export function EntryForm({ ops, registry, collection, id }) {
374
411
  trailing: multilingual && (_jsx(LocaleSwitcher, { registry: registry, present: present, parentLocales: parentLocales, value: locale, adding: adding, onSwitch: (l) => { void switchLocale(l); }, onAdd: (l) => void doAddLocale(l) })),
375
412
  },
376
413
  { label: heading },
377
- ] }) }), right: _jsxs(_Fragment, { children: [(touched || saveState === "error") && _jsx(SaveIndicator, { state: saveState }), urls[locale] && (_jsx(LinkButton, { icon: true, href: urls[locale], target: "_blank", rel: "noreferrer", "aria-label": "View live", title: "View live \u2014 opens the site route. While the admin's draft-mode cookie is on, this shows the DRAFT \u2014 open it in a private window to see what visitors see.", children: _jsx(Eye, { size: 16, strokeWidth: 1.5, "aria-hidden": "true" }) })), _jsx(PublishControl, { status: status, pending: dirty || saveState !== "saved", busy: busy, flash: published, disabled: !row, onPublish: () => { void publish(); }, onUnpublish: () => { void unpublish(); }, onDiscard: () => { void discard(); }, onHistory: () => { void openHistory(); } })] }) }), history && row && (_jsx(HistoryModal, { rows: history, draftId: row.draftVersionId, onSelect: (v) => { void loadSnapshot(v); }, onClose: () => setHistory(null), onRestore: restore, render: (versionId) => snapshot?.id === versionId ? (_jsx("fieldset", { disabled: true, className: "sm-history__form", children: Object.entries(meta.fields).map(([key, d]) => (_jsx(FieldWidget, { fieldKey: key, descriptor: d, value: snapshot.fields[key], refOptions: refTargets[key] ? refOptions[refTargets[key]] : undefined, prefix: d.type === "slug" ? slugPrefix : undefined, onChange: () => { } }, key))) })) : _jsx("p", { className: "sm-hint", style: { padding: 16 }, children: "Loading\u2026" }) })), _jsxs("div", { className: "sm-editor", children: [_jsxs("div", { className: "sm-form", ref: formRef, children: [notice && (_jsxs(Banner, { tone: notice.tone, onDismiss: () => setNotice(null), children: [_jsx("p", { style: { margin: 0 }, children: notice.text }), _jsx(FieldErrorList, { fields: notice.fields ?? [], onReveal: (path) => { if (formRef.current)
414
+ ] }) }), right: _jsxs(_Fragment, { children: [(touched || saveState === "error") && _jsx(SaveIndicator, { state: saveState, pending: dirty }), urls[locale] && (_jsx(LinkButton, { icon: true, href: urls[locale], target: "_blank", rel: "noreferrer", "aria-label": "View live", title: "View live \u2014 opens the site route. While the admin's draft-mode cookie is on, this shows the DRAFT \u2014 open it in a private window to see what visitors see.", children: _jsx(Eye, { size: 16, strokeWidth: 1.5, "aria-hidden": "true" }) })), _jsx(PublishControl, { status: status, pending: dirty || saveState !== "saved", busy: busy, flash: published, disabled: !row, onPublish: () => { void publish(); }, onUnpublish: () => { void unpublish(); }, onDiscard: () => { void discard(); }, onHistory: () => { void openHistory(); } })] }) }), history && row && (_jsx(HistoryModal, { rows: history, draftId: row.draftVersionId, onSelect: (v) => { void loadSnapshot(v); }, onClose: () => setHistory(null), onRestore: restore, render: (versionId) => snapshot?.id === versionId ? (_jsx("fieldset", { disabled: true, className: "sm-history__form", children: Object.entries(meta.fields).map(([key, d]) => (_jsx(FieldWidget, { fieldKey: key, descriptor: d, value: snapshot.fields[key], refOptions: refTargets[key] ? refOptions[refTargets[key]] : undefined, prefix: d.type === "slug" ? slugPrefix : undefined, onChange: () => { } }, key))) })) : _jsx("p", { className: "sm-hint", style: { padding: 16 }, children: "Loading\u2026" }) })), _jsx(PresenceLine, { names: editorsOf(others, target), noun: "entry" }), stale && _jsx(StaleBanner, { info: stale, onReload: loadTheirs }), _jsxs("div", { className: "sm-editor", inert: stale ? true : undefined, children: [_jsxs("div", { className: "sm-form", ref: formRef, children: [notice && (_jsxs(Banner, { tone: notice.tone, onDismiss: () => setNotice(null), children: [_jsx("p", { style: { margin: 0 }, children: notice.text }), _jsx(FieldErrorList, { fields: notice.fields ?? [], onReveal: (path) => { if (formRef.current)
378
415
  revealField(formRef.current, path); } })] })), absent ? (_jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 12 }, children: [_jsxs("p", { children: ["This entry doesn't exist in ", locale, " yet. Adding it copies the ", entrySourceLocale(record, registry), " fields as the first ", locale, " draft."] }), _jsxs("div", { style: { display: "flex", gap: 8 }, children: [_jsxs(Button, { onClick: () => void doAddLocale(locale), disabled: adding || !parentLocales.includes(locale), title: parentLocales.includes(locale) ? undefined : `add ${locale} to the parent first`, children: ["Add ", locale] }), _jsxs(Button, { variant: "secondary", onClick: () => { void switchLocale(entrySourceLocale(record, registry)); }, children: ["Back to ", entrySourceLocale(record, registry)] })] })] })) : fields !== null ? (Object.entries(meta.fields).map(widget)) : !notice && _jsx("p", { style: { color: "var(--text-muted)" }, children: "Loading\u2026" })] }), _jsx(ResizeHandle, {}), _jsxs("aside", { className: "sm-inspector", children: [record && row && (_jsxs("div", { className: "sm-card", children: [_jsx(SectionLabel, { children: "Document info" }), _jsx(InfoRow, { label: "Created", value: relativeTime(record.createdAt) }), _jsx(InfoRow, { label: "Updated", value: updatedLine(row.updatedAt, row.updatedBy) }), multilingual && _jsx(InfoRow, { label: "Languages", value: Object.keys(record.locales).join(", ") }), _jsx(InfoRow, { label: "ID", value: id, mono: true })] })), record && (_jsxs("div", { className: "sm-card", children: [multilingual && row && Object.keys(record.locales).length > 1 && (_jsxs(Button, { variant: "secondary", onClick: () => void doRemoveLocale(), children: ["Remove from ", locale] })), _jsx(Button, { variant: "danger", onClick: destroy, children: "Delete" })] }))] })] })] }));
379
416
  }