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.
- package/README.md +66 -2
- package/dist/admin/editor/EditorView.js +82 -18
- package/dist/admin/editor/autosave.d.ts +4 -1
- package/dist/admin/editor/autosave.js +24 -1
- package/dist/admin/editor/global-edit.d.ts +5 -1
- package/dist/admin/editor/global-edit.js +15 -5
- package/dist/admin/editor/useGlobalEdit.d.ts +1 -1
- package/dist/admin/editor/useGlobalEdit.js +1 -1
- package/dist/admin/next/create-admin.d.ts +4 -0
- package/dist/admin/next/create-admin.js +3 -1
- package/dist/admin/next/index.d.ts +1 -0
- package/dist/admin/ops-impl.js +70 -24
- package/dist/admin/ops.d.ts +22 -12
- package/dist/admin/shell/AdminApp.d.ts +3 -1
- package/dist/admin/shell/AdminApp.js +36 -3
- package/dist/admin/shell/EntriesList.js +5 -1
- package/dist/admin/shell/EntryForm.js +44 -7
- package/dist/admin/shell/GlobalForm.js +46 -7
- package/dist/admin/shell/GlobalList.js +6 -2
- package/dist/admin/shell/PagesList.js +5 -1
- package/dist/admin/shell/presence-context.d.ts +9 -0
- package/dist/admin/shell/presence-context.js +35 -0
- package/dist/admin/shell/presence-realtime.d.ts +3 -0
- package/dist/admin/shell/presence-realtime.js +32 -0
- package/dist/admin/shell/presence.d.ts +69 -0
- package/dist/admin/shell/presence.js +103 -0
- package/dist/admin/shell/stale.d.ts +28 -0
- package/dist/admin/shell/stale.js +17 -0
- package/dist/admin/ui/LockIcon.d.ts +4 -0
- package/dist/admin/ui/LockIcon.js +12 -0
- package/dist/admin/ui/PresenceLine.d.ts +6 -0
- package/dist/admin/ui/PresenceLine.js +11 -0
- package/dist/admin/ui/StaleBanner.d.ts +5 -0
- package/dist/admin/ui/StaleBanner.js +9 -0
- package/dist/admin/ui/primitives.d.ts +4 -1
- package/dist/admin/ui/primitives.js +5 -2
- package/dist/admin/ui/theme.js +4 -0
- package/dist/draft-token.d.ts +31 -0
- package/dist/draft-token.js +41 -0
- package/dist/entry-store.d.ts +11 -8
- package/dist/entry-store.js +24 -10
- package/dist/env.d.ts +9 -0
- package/dist/env.js +19 -0
- package/dist/index.d.ts +4 -2
- package/dist/index.js +2 -1
- package/dist/schema-version.d.ts +1 -1
- package/dist/schema-version.js +1 -1
- package/dist/sql-space.d.ts +6 -0
- package/dist/sql-space.js +41 -3
- package/dist/sql.js +16 -4
- package/dist/store.d.ts +11 -8
- package/dist/store.js +26 -8
- package/dist/supabase-entry-store.d.ts +15 -5
- package/dist/supabase-entry-store.js +87 -22
- package/dist/supabase-store.d.ts +15 -5
- package/dist/supabase-store.js +60 -20
- package/migrations/20260926130000_smoodly_v2.sql +26 -0
- package/migrations/20260926140000_smoodly_v3.sql +38 -0
- package/package.json +1 -1
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export type DraftToken = {
|
|
2
|
+
versionId: string;
|
|
3
|
+
savedAt: string;
|
|
4
|
+
};
|
|
5
|
+
/** What a locale row carries for the token (both stores' row types satisfy it). */
|
|
6
|
+
export type DraftPointerRow = {
|
|
7
|
+
draftVersionId: string | null;
|
|
8
|
+
draftSavedAt: string | null;
|
|
9
|
+
};
|
|
10
|
+
/** The row's token, or null when it has no draft pointer (a folder, a page created without a tree). */
|
|
11
|
+
export declare function draftToken(row: DraftPointerRow): DraftToken | null;
|
|
12
|
+
export declare function sameToken(a: DraftToken | null, b: DraftToken | null): boolean;
|
|
13
|
+
/** An untrusted value is a token when it has the two strings and nothing is missing. */
|
|
14
|
+
export declare function isDraftToken(v: unknown): v is DraftToken;
|
|
15
|
+
export declare class StaleDraftError extends Error {
|
|
16
|
+
/** The row's token now — what a reload would get. */
|
|
17
|
+
readonly current: DraftToken | null;
|
|
18
|
+
/** The audit pair of the row: who wrote last, when. */
|
|
19
|
+
readonly by: string | null;
|
|
20
|
+
readonly at: number;
|
|
21
|
+
constructor(
|
|
22
|
+
/** The row's token now — what a reload would get. */
|
|
23
|
+
current: DraftToken | null,
|
|
24
|
+
/** The audit pair of the row: who wrote last, when. */
|
|
25
|
+
by: string | null, at: number);
|
|
26
|
+
}
|
|
27
|
+
/** The read-side check every guarded write runs first. `undefined` = no check. */
|
|
28
|
+
export declare function assertFresh(row: DraftPointerRow & {
|
|
29
|
+
updatedBy: string | null;
|
|
30
|
+
updatedAt: number;
|
|
31
|
+
}, expected: DraftToken | null | undefined): void;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// The draft token (spec 2026-09-26 concurrency §1): the locale row's
|
|
2
|
+
// draft pointer with that version's saved time. Every state change moves
|
|
3
|
+
// it — a save rewrites saved_at, a discard points at the published
|
|
4
|
+
// version, a restore creates a new draft. Pure and framework-free; both
|
|
5
|
+
// stores throw StaleDraftError, the ops map it, the admin freezes on it.
|
|
6
|
+
/** The row's token, or null when it has no draft pointer (a folder, a page created without a tree). */
|
|
7
|
+
export function draftToken(row) {
|
|
8
|
+
return row.draftVersionId === null ? null : { versionId: row.draftVersionId, savedAt: row.draftSavedAt ?? "" };
|
|
9
|
+
}
|
|
10
|
+
export function sameToken(a, b) {
|
|
11
|
+
if (a === null || b === null)
|
|
12
|
+
return a === b;
|
|
13
|
+
return a.versionId === b.versionId && a.savedAt === b.savedAt;
|
|
14
|
+
}
|
|
15
|
+
/** An untrusted value is a token when it has the two strings and nothing is missing. */
|
|
16
|
+
export function isDraftToken(v) {
|
|
17
|
+
return typeof v === "object" && v !== null && !Array.isArray(v)
|
|
18
|
+
&& typeof v.versionId === "string"
|
|
19
|
+
&& typeof v.savedAt === "string";
|
|
20
|
+
}
|
|
21
|
+
export class StaleDraftError extends Error {
|
|
22
|
+
constructor(
|
|
23
|
+
/** The row's token now — what a reload would get. */
|
|
24
|
+
current,
|
|
25
|
+
/** The audit pair of the row: who wrote last, when. */
|
|
26
|
+
by, at) {
|
|
27
|
+
super("smoodly: the draft changed since it was loaded — reload before saving.");
|
|
28
|
+
this.current = current;
|
|
29
|
+
this.by = by;
|
|
30
|
+
this.at = at;
|
|
31
|
+
this.name = "StaleDraftError";
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
/** The read-side check every guarded write runs first. `undefined` = no check. */
|
|
35
|
+
export function assertFresh(row, expected) {
|
|
36
|
+
if (expected === undefined)
|
|
37
|
+
return;
|
|
38
|
+
const current = draftToken(row);
|
|
39
|
+
if (!sameToken(current, expected))
|
|
40
|
+
throw new StaleDraftError(current, row.updatedBy, row.updatedAt);
|
|
41
|
+
}
|
package/dist/entry-store.d.ts
CHANGED
|
@@ -3,6 +3,7 @@ import type { GlobalSchema } from "./global.ts";
|
|
|
3
3
|
import { type RefEdge, type SectionLike } from "./refs.ts";
|
|
4
4
|
import { MemoryRefIndex } from "./ref-index.ts";
|
|
5
5
|
import { type PathIndex } from "./path-index.ts";
|
|
6
|
+
import { type DraftToken } from "./draft-token.ts";
|
|
6
7
|
import { type LocaleSet, type PathTarget } from "./paths.ts";
|
|
7
8
|
export type EntryLocale = {
|
|
8
9
|
/** The entry's segment in this locale; null claims no URL. */
|
|
@@ -11,6 +12,8 @@ export type EntryLocale = {
|
|
|
11
12
|
/** ONLY the .localized() fields, for this locale. */
|
|
12
13
|
fields: Record<string, unknown>;
|
|
13
14
|
draftVersionId: string | null;
|
|
15
|
+
/** The draft version's saved time as the store's own text (spec 2026-09-26 concurrency §1). Null with no pointer. */
|
|
16
|
+
draftSavedAt: string | null;
|
|
14
17
|
publishedVersionId: string | null;
|
|
15
18
|
updatedAt: number;
|
|
16
19
|
/** The editor's email behind the last write that touched this row; null for the seed and scripts (spec 2026-09-13 history §2). */
|
|
@@ -104,14 +107,14 @@ export interface EntryStore {
|
|
|
104
107
|
}): Promise<Record<string, Record<string, unknown>>>;
|
|
105
108
|
/** Replaces the merged fields for `locale`: shared keys on the node
|
|
106
109
|
* (every locale sees them), localized keys and the slug on the row. */
|
|
107
|
-
update(collection: string, id: string, locale: string, fields: Record<string, unknown>, by?: string): Promise<EntryRecord>;
|
|
108
|
-
setStatus(collection: string, id: string, locale: string, status: "draft" | "published", by?: string): Promise<EntryRecord>;
|
|
110
|
+
update(collection: string, id: string, locale: string, fields: Record<string, unknown>, by?: string, expected?: DraftToken | null): Promise<EntryRecord>;
|
|
111
|
+
setStatus(collection: string, id: string, locale: string, status: "draft" | "published", by?: string, expected?: DraftToken | null): Promise<EntryRecord>;
|
|
109
112
|
/** The live row goes back to what is published: the published snapshot's fields
|
|
110
113
|
* (shared and localized, split as on save — the slug is never versioned) written over
|
|
111
114
|
* the row, the draft pointer set to the published one, the abandoned draft row deleted
|
|
112
115
|
* (spec 2026-09-13 history §1). Refused when the locale has no published version (a
|
|
113
116
|
* non-versioned collection never has one). */
|
|
114
|
-
discardDraft(collection: string, id: string, locale: string, by?: string): Promise<EntryRecord>;
|
|
117
|
+
discardDraft(collection: string, id: string, locale: string, by?: string, expected?: DraftToken | null): Promise<EntryRecord>;
|
|
115
118
|
/** Copies `from`'s localized fields and slug as the new row (shared
|
|
116
119
|
* fields need no copy); refused when the entry has the locale or its
|
|
117
120
|
* parent lacks it. */
|
|
@@ -124,7 +127,7 @@ export interface EntryStore {
|
|
|
124
127
|
/** The snapshot's shared and localized fields become the draft (the slug is never
|
|
125
128
|
* versioned) through the draft rule; the live version restores as a discard. Refused
|
|
126
129
|
* when the version is not this entry's in this locale (spec 2026-09-13 history §6). */
|
|
127
|
-
restore(collection: string, id: string, locale: string, versionId: string, by?: string): Promise<EntryRecord>;
|
|
130
|
+
restore(collection: string, id: string, locale: string, versionId: string, by?: string, expected?: DraftToken | null): Promise<EntryRecord>;
|
|
128
131
|
/** One stored version by id — callers check `entryId` and `locale`. */
|
|
129
132
|
getVersion(collection: string, versionId: string): Promise<EntryVersion | null>;
|
|
130
133
|
/** Blocked while anything references the entry (the safe-delete guard),
|
|
@@ -268,13 +271,13 @@ export declare class MemoryEntryStore implements EntryStore {
|
|
|
268
271
|
getMany(collection: string, ids: string[], locale: string, options?: {
|
|
269
272
|
status?: "published";
|
|
270
273
|
}): Promise<Record<string, Record<string, unknown>>>;
|
|
271
|
-
update(collection: string, id: string, locale: string, fields: Record<string, unknown>, by?: string): Promise<EntryRecord>;
|
|
272
|
-
setStatus(collection: string, id: string, locale: string, status: "draft" | "published", by?: string): Promise<EntryRecord>;
|
|
273
|
-
discardDraft(collection: string, id: string, locale: string, by?: string): Promise<EntryRecord>;
|
|
274
|
+
update(collection: string, id: string, locale: string, fields: Record<string, unknown>, by?: string, expected?: DraftToken | null): Promise<EntryRecord>;
|
|
275
|
+
setStatus(collection: string, id: string, locale: string, status: "draft" | "published", by?: string, expected?: DraftToken | null): Promise<EntryRecord>;
|
|
276
|
+
discardDraft(collection: string, id: string, locale: string, by?: string, expected?: DraftToken | null): Promise<EntryRecord>;
|
|
274
277
|
addLocale(collection: string, id: string, locale: string, options: AddEntryLocaleOptions, by?: string): Promise<EntryRecord>;
|
|
275
278
|
removeLocale(collection: string, id: string, locale: string): Promise<EntryRecord>;
|
|
276
279
|
listVersions(collection: string, id: string, locale: string): Promise<EntryVersion[]>;
|
|
277
|
-
restore(collection: string, id: string, locale: string, versionId: string, by?: string): Promise<EntryRecord>;
|
|
280
|
+
restore(collection: string, id: string, locale: string, versionId: string, by?: string, expected?: DraftToken | null): Promise<EntryRecord>;
|
|
278
281
|
getVersion(collection: string, versionId: string): Promise<EntryVersion | null>;
|
|
279
282
|
move(collection: string, id: string, to: {
|
|
280
283
|
parentId: string | null;
|
package/dist/entry-store.js
CHANGED
|
@@ -13,6 +13,7 @@ import { collectionDepth, collectionOrder } from "./collections.js";
|
|
|
13
13
|
import { collectEntryRefs } from "./refs.js";
|
|
14
14
|
import { MemoryRefIndex } from "./ref-index.js";
|
|
15
15
|
import { MemoryPathIndex } from "./path-index.js";
|
|
16
|
+
import { assertFresh } from "./draft-token.js";
|
|
16
17
|
import { globalEntryId } from "./global-id.js";
|
|
17
18
|
import { DEFAULT_SPACE_ID } from "./sql-space.js";
|
|
18
19
|
import { assertDepth, assertSupportedLocale, chainOf, entryPathRows, slugOf, unsupportedLocale, } from "./paths.js";
|
|
@@ -44,6 +45,11 @@ export function schemaOf(collections, name) {
|
|
|
44
45
|
export const isGlobalSchema = (schema) => schema.kind === "global";
|
|
45
46
|
let counter = 0;
|
|
46
47
|
const uid = (prefix) => `${prefix}-${++counter}-${Date.now().toString(36)}`;
|
|
48
|
+
/** A version's saved time: wall-clock, but strictly increasing, so two saves in one
|
|
49
|
+
* millisecond still move the draft token (spec 2026-09-26 concurrency §1) the way
|
|
50
|
+
* Postgres's microsecond `now()` does. */
|
|
51
|
+
let lastSavedAt = 0;
|
|
52
|
+
const savedClock = () => (lastSavedAt = Math.max(Date.now(), lastSavedAt + 1));
|
|
47
53
|
export const DEFAULT_ENTRY_LOCALES = { default: "en", supported: ["en"] };
|
|
48
54
|
export const entryErrors = {
|
|
49
55
|
notFound: (collection, id) => new Error(`smoodly: no entry "${id}" in "${collection}".`),
|
|
@@ -216,14 +222,16 @@ export class MemoryEntryStore {
|
|
|
216
222
|
const current = row.draftVersionId ? this.versions.get(row.draftVersionId) : undefined;
|
|
217
223
|
if (current && row.draftVersionId !== row.publishedVersionId) {
|
|
218
224
|
current.fields = fields;
|
|
219
|
-
current.savedAt =
|
|
225
|
+
current.savedAt = savedClock();
|
|
226
|
+
row.draftSavedAt = String(current.savedAt);
|
|
220
227
|
this.versionSeq.set(current.id, ++this.seq); // newest again
|
|
221
228
|
return;
|
|
222
229
|
}
|
|
223
|
-
const version = { id: uid("version"), entryId: entry.id, locale, fields, savedAt:
|
|
230
|
+
const version = { id: uid("version"), entryId: entry.id, locale, fields, savedAt: savedClock() };
|
|
224
231
|
this.versions.set(version.id, version);
|
|
225
232
|
this.versionSeq.set(version.id, ++this.seq);
|
|
226
233
|
row.draftVersionId = version.id;
|
|
234
|
+
row.draftSavedAt = String(version.savedAt);
|
|
227
235
|
}
|
|
228
236
|
/** A published row from before every collection was versioned has no snapshot; give it
|
|
229
237
|
* one from what is live BEFORE a write changes the fields, so the site keeps serving it
|
|
@@ -232,11 +240,12 @@ export class MemoryEntryStore {
|
|
|
232
240
|
const row = entry.locales[locale];
|
|
233
241
|
if (row.status !== "published" || row.publishedVersionId !== null)
|
|
234
242
|
return;
|
|
235
|
-
const version = { id: uid("version"), entryId: entry.id, locale, fields: entryFieldsIn(entry, locale), savedAt:
|
|
243
|
+
const version = { id: uid("version"), entryId: entry.id, locale, fields: entryFieldsIn(entry, locale), savedAt: savedClock() };
|
|
236
244
|
this.versions.set(version.id, version);
|
|
237
245
|
this.versionSeq.set(version.id, ++this.seq);
|
|
238
246
|
row.draftVersionId = version.id;
|
|
239
247
|
row.publishedVersionId = version.id;
|
|
248
|
+
row.draftSavedAt = String(version.savedAt);
|
|
240
249
|
}
|
|
241
250
|
dropVersions(entryId, locale) {
|
|
242
251
|
for (const [id, v] of this.versions) {
|
|
@@ -323,7 +332,7 @@ export class MemoryEntryStore {
|
|
|
323
332
|
collection, parentId, fields: shared,
|
|
324
333
|
locales: {
|
|
325
334
|
[options.locale]: {
|
|
326
|
-
slug, status: "draft", fields: localized, draftVersionId: null, publishedVersionId: null, updatedAt: now, updatedBy: by ?? null,
|
|
335
|
+
slug, status: "draft", fields: localized, draftVersionId: null, draftSavedAt: null, publishedVersionId: null, updatedAt: now, updatedBy: by ?? null,
|
|
327
336
|
},
|
|
328
337
|
},
|
|
329
338
|
sort: null, createdAt: now, updatedAt: now,
|
|
@@ -387,10 +396,11 @@ export class MemoryEntryStore {
|
|
|
387
396
|
}
|
|
388
397
|
return out;
|
|
389
398
|
}
|
|
390
|
-
async update(collection, id, locale, fields, by) {
|
|
399
|
+
async update(collection, id, locale, fields, by, expected) {
|
|
391
400
|
const schema = this.schema(collection);
|
|
392
401
|
const entry = this.must(collection, id);
|
|
393
402
|
const row = entryLocaleRow(entry, locale);
|
|
403
|
+
assertFresh(row, expected);
|
|
394
404
|
this.ensureLiveSnapshot(entry, locale);
|
|
395
405
|
const { shared, localized, slug } = splitFields(fields, schema);
|
|
396
406
|
if (isGlobalSchema(schema) && slug !== null)
|
|
@@ -413,10 +423,11 @@ export class MemoryEntryStore {
|
|
|
413
423
|
this.writeRefs(entry);
|
|
414
424
|
return this.snapshot(entry);
|
|
415
425
|
}
|
|
416
|
-
async setStatus(collection, id, locale, status, by) {
|
|
426
|
+
async setStatus(collection, id, locale, status, by, expected) {
|
|
417
427
|
const entry = this.must(collection, id);
|
|
418
428
|
const row = entryLocaleRow(entry, locale);
|
|
419
429
|
if (status === "published") {
|
|
430
|
+
assertFresh(row, expected);
|
|
420
431
|
// Publish points at the current draft; a row always has one after its first write, but never trust that from here.
|
|
421
432
|
if (!row.draftVersionId)
|
|
422
433
|
this.recordVersion(entry, locale);
|
|
@@ -428,10 +439,11 @@ export class MemoryEntryStore {
|
|
|
428
439
|
this.writeRefs(entry); // the published snapshot just became, or stopped being, a live set
|
|
429
440
|
return this.snapshot(entry);
|
|
430
441
|
}
|
|
431
|
-
async discardDraft(collection, id, locale, by) {
|
|
442
|
+
async discardDraft(collection, id, locale, by, expected) {
|
|
432
443
|
const schema = this.schema(collection);
|
|
433
444
|
const entry = this.must(collection, id);
|
|
434
445
|
const row = entryLocaleRow(entry, locale);
|
|
446
|
+
assertFresh(row, expected);
|
|
435
447
|
const snapshot = row.publishedVersionId ? this.versions.get(row.publishedVersionId) : undefined;
|
|
436
448
|
if (!snapshot)
|
|
437
449
|
throw entryErrors.nothingPublished(collection, id, locale);
|
|
@@ -440,6 +452,7 @@ export class MemoryEntryStore {
|
|
|
440
452
|
row.fields = localized;
|
|
441
453
|
const abandoned = row.draftVersionId;
|
|
442
454
|
row.draftVersionId = row.publishedVersionId;
|
|
455
|
+
row.draftSavedAt = String(snapshot.savedAt);
|
|
443
456
|
if (abandoned && abandoned !== row.publishedVersionId) {
|
|
444
457
|
this.versions.delete(abandoned);
|
|
445
458
|
this.versionSeq.delete(abandoned);
|
|
@@ -462,7 +475,7 @@ export class MemoryEntryStore {
|
|
|
462
475
|
const now = Date.now();
|
|
463
476
|
entry.locales[locale] = {
|
|
464
477
|
slug: source.slug, status: "draft", fields: structuredClone(source.fields),
|
|
465
|
-
draftVersionId: null, publishedVersionId: null, updatedAt: now, updatedBy: by ?? null,
|
|
478
|
+
draftVersionId: null, draftSavedAt: null, publishedVersionId: null, updatedAt: now, updatedBy: by ?? null,
|
|
466
479
|
};
|
|
467
480
|
this.recordVersion(entry, locale);
|
|
468
481
|
// Only this node's rows change: a child cannot have a locale its parent lacked.
|
|
@@ -510,15 +523,16 @@ export class MemoryEntryStore {
|
|
|
510
523
|
.sort((a, b) => b.savedAt - a.savedAt || this.versionSeq.get(b.id) - this.versionSeq.get(a.id))
|
|
511
524
|
.map((v) => structuredClone(v));
|
|
512
525
|
}
|
|
513
|
-
async restore(collection, id, locale, versionId, by) {
|
|
526
|
+
async restore(collection, id, locale, versionId, by, expected) {
|
|
514
527
|
const schema = this.schema(collection);
|
|
515
528
|
const entry = this.must(collection, id);
|
|
516
529
|
const row = entryLocaleRow(entry, locale);
|
|
517
530
|
const version = this.versions.get(versionId);
|
|
518
531
|
if (!version || version.entryId !== id || version.locale !== locale)
|
|
519
532
|
throw entryErrors.foreignVersion(collection, id, locale, versionId);
|
|
533
|
+
assertFresh(row, expected);
|
|
520
534
|
if (versionId === row.publishedVersionId)
|
|
521
|
-
return this.discardDraft(collection, id, locale, by);
|
|
535
|
+
return this.discardDraft(collection, id, locale, by, expected);
|
|
522
536
|
const { shared, localized } = splitFields(version.fields, schema);
|
|
523
537
|
entry.fields = shared;
|
|
524
538
|
row.fields = localized; // the slug is the row's own and never versioned
|
package/dist/env.d.ts
CHANGED
|
@@ -14,3 +14,12 @@ export declare function resolveSmoodlyEnv(env: Record<string, string | undefined
|
|
|
14
14
|
/** `resolveSmoodlyEnv(process.env)` — call lazily, never at module load,
|
|
15
15
|
* so `next build` without env still succeeds. */
|
|
16
16
|
export declare const smoodlyEnv: () => SmoodlyEnv;
|
|
17
|
+
/** What the browser needs to join the content project's presence channel (spec 2026-09-26 concurrency §5): public values only. */
|
|
18
|
+
export type ContentConfig = {
|
|
19
|
+
url: string;
|
|
20
|
+
key: string;
|
|
21
|
+
spaceId: string;
|
|
22
|
+
};
|
|
23
|
+
/** The space a key acts in: its `space_id` claim when it is a JWT that carries one, else the default space. */
|
|
24
|
+
export declare function spaceIdOfKey(key: string): string;
|
|
25
|
+
export declare function publicContentConfig(env: SmoodlyEnv): ContentConfig;
|
package/dist/env.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// The one config shape (spec 2026-09-04 §4/§6). Self-host sets three
|
|
2
2
|
// names; the cloud sets five. The two defaults are what keep a single
|
|
3
3
|
// code path: a default for an absent value is not a mode branch.
|
|
4
|
+
import { DEFAULT_SPACE_ID } from "./sql-space.js";
|
|
4
5
|
const REQUIRED = ["SMOODLY_URL", "SMOODLY_SECRET_KEY", "SMOODLY_PUBLISHABLE_KEY"];
|
|
5
6
|
export function resolveSmoodlyEnv(env) {
|
|
6
7
|
const missing = REQUIRED.filter((name) => !env[name]);
|
|
@@ -20,3 +21,21 @@ export function resolveSmoodlyEnv(env) {
|
|
|
20
21
|
/** `resolveSmoodlyEnv(process.env)` — call lazily, never at module load,
|
|
21
22
|
* so `next build` without env still succeeds. */
|
|
22
23
|
export const smoodlyEnv = () => resolveSmoodlyEnv(process.env);
|
|
24
|
+
/** The space a key acts in: its `space_id` claim when it is a JWT that carries one, else the default space. */
|
|
25
|
+
export function spaceIdOfKey(key) {
|
|
26
|
+
const parts = key.split(".");
|
|
27
|
+
if (parts.length !== 3)
|
|
28
|
+
return DEFAULT_SPACE_ID;
|
|
29
|
+
try {
|
|
30
|
+
// base64url → base64, padded; atob exists in Node 22 and every browser, so no Buffer here.
|
|
31
|
+
const b64 = parts[1].replace(/-/g, "+").replace(/_/g, "/").padEnd(Math.ceil(parts[1].length / 4) * 4, "=");
|
|
32
|
+
const claims = JSON.parse(atob(b64));
|
|
33
|
+
return typeof claims.space_id === "string" && claims.space_id ? claims.space_id : DEFAULT_SPACE_ID;
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return DEFAULT_SPACE_ID;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
export function publicContentConfig(env) {
|
|
40
|
+
return { url: env.url, key: env.publishableKey, spaceId: spaceIdOfKey(env.secretKey) };
|
|
41
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -28,6 +28,8 @@ export { z } from "./zones.ts";
|
|
|
28
28
|
export { defineConfig } from "./config.ts";
|
|
29
29
|
export { collectionSQL, coreTablesSQL } from "./sql.ts";
|
|
30
30
|
export { SCHEMA_VERSION, SCHEMA_VERSION_KEY, schemaVersionSQL, coreFileVersion, schemaBehind, schemaBehindMessage } from "./schema-version.ts";
|
|
31
|
+
export { draftToken, sameToken, isDraftToken, assertFresh, StaleDraftError } from "./draft-token.ts";
|
|
32
|
+
export type { DraftToken, DraftPointerRow } from "./draft-token.ts";
|
|
31
33
|
export { MemoryPageStore } from "./store.ts";
|
|
32
34
|
export { MemoryAssetStore, assetErrors, kindOf, extensionFor, objectPath, withDownload, PAGE_SIZE as ASSET_PAGE_SIZE } from "./asset-store.ts";
|
|
33
35
|
export type { AssetStore, UploadTicket, FinishInput, AssetQuery, AssetPage, BeginUploadInput, CreateAssetInput } from "./asset-store.ts";
|
|
@@ -153,8 +155,8 @@ export declare const smoodly: {
|
|
|
153
155
|
};
|
|
154
156
|
export { NODE_ATTR, ZONE_ATTR, EMPTY_ATTR, BLANK_ATTR, FIELD_ATTR, ITEM_ATTR, EDITOR_ATTR, resolveMarkPath } from "./marks.ts";
|
|
155
157
|
export type { MarkNode, MarkPath, Marks, Scope } from "./marks.ts";
|
|
156
|
-
export { resolveSmoodlyEnv, smoodlyEnv } from "./env.ts";
|
|
157
|
-
export type { SmoodlyEnv } from "./env.ts";
|
|
158
|
+
export { resolveSmoodlyEnv, smoodlyEnv, publicContentConfig, spaceIdOfKey } from "./env.ts";
|
|
159
|
+
export type { SmoodlyEnv, ContentConfig } from "./env.ts";
|
|
158
160
|
export { clientOptions, createSmoodlyClient } from "./client.ts";
|
|
159
161
|
export { createSmoodlySite, pageContextOf } from "./site.ts";
|
|
160
162
|
export type { SiteOptions, SitePage, SitePageSchema, SiteEntry, SiteHit, SmoodlySite, LoadOptions, PageTreeItem, SiteEntryItem, EntryTreeItem, EntryQuery, } from "./site.ts";
|
package/dist/index.js
CHANGED
|
@@ -22,6 +22,7 @@ export { z } from "./zones.js";
|
|
|
22
22
|
export { defineConfig } from "./config.js";
|
|
23
23
|
export { collectionSQL, coreTablesSQL } from "./sql.js";
|
|
24
24
|
export { SCHEMA_VERSION, SCHEMA_VERSION_KEY, schemaVersionSQL, coreFileVersion, schemaBehind, schemaBehindMessage } from "./schema-version.js";
|
|
25
|
+
export { draftToken, sameToken, isDraftToken, assertFresh, StaleDraftError } from "./draft-token.js";
|
|
25
26
|
export { MemoryPageStore } from "./store.js";
|
|
26
27
|
export { MemoryAssetStore, assetErrors, kindOf, extensionFor, objectPath, withDownload, PAGE_SIZE as ASSET_PAGE_SIZE } from "./asset-store.js";
|
|
27
28
|
export { imageDimensions } from "./image-dimensions.js";
|
|
@@ -63,6 +64,6 @@ export const smoodly = {
|
|
|
63
64
|
marks,
|
|
64
65
|
};
|
|
65
66
|
export { NODE_ATTR, ZONE_ATTR, EMPTY_ATTR, BLANK_ATTR, FIELD_ATTR, ITEM_ATTR, EDITOR_ATTR, resolveMarkPath } from "./marks.js";
|
|
66
|
-
export { resolveSmoodlyEnv, smoodlyEnv } from "./env.js";
|
|
67
|
+
export { resolveSmoodlyEnv, smoodlyEnv, publicContentConfig, spaceIdOfKey } from "./env.js";
|
|
67
68
|
export { clientOptions, createSmoodlyClient } from "./client.js";
|
|
68
69
|
export { createSmoodlySite, pageContextOf } from "./site.js";
|
package/dist/schema-version.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/** The minimum schema version the package needs. Moves ONLY in a release
|
|
2
2
|
* that adds a table, column, function or policy the package reads; a
|
|
3
3
|
* unit test holds it equal to the highest file in `migrations/`. */
|
|
4
|
-
export declare const SCHEMA_VERSION =
|
|
4
|
+
export declare const SCHEMA_VERSION = 3;
|
|
5
5
|
export declare const SCHEMA_VERSION_KEY = "schema_version";
|
|
6
6
|
export declare const CORE_FILE_RE: RegExp;
|
|
7
7
|
export declare const VIEWS_FILE_RE: RegExp;
|
package/dist/schema-version.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
/** The minimum schema version the package needs. Moves ONLY in a release
|
|
8
8
|
* that adds a table, column, function or policy the package reads; a
|
|
9
9
|
* unit test holds it equal to the highest file in `migrations/`. */
|
|
10
|
-
export const SCHEMA_VERSION =
|
|
10
|
+
export const SCHEMA_VERSION = 3;
|
|
11
11
|
export const SCHEMA_VERSION_KEY = "schema_version";
|
|
12
12
|
export const CORE_FILE_RE = /^(\d{14})_smoodly_v(\d+)\.sql$/;
|
|
13
13
|
export const VIEWS_FILE_RE = /^(\d{14})_smoodly_views\.sql$/;
|
package/dist/sql-space.d.ts
CHANGED
|
@@ -15,3 +15,9 @@ export declare function spaceLayerSQL(): string;
|
|
|
15
15
|
* Schema-qualified calls: storage policies do not run with public on
|
|
16
16
|
* the search path. Last in the migration, after the functions exist. */
|
|
17
17
|
export declare function storageSQL(): string;
|
|
18
|
+
/** The three mutation RPCs are the stores' verbs, never a browser's (spec
|
|
19
|
+
* 2026-09-26 security pass §2): executable by the service role and by
|
|
20
|
+
* `authenticated` (the role a cloud write key carries), not by anon or
|
|
21
|
+
* public. `smoodly_current_space` is NOT here — the stores read it through
|
|
22
|
+
* rpc(). Last in the schema, after every definition exists. */
|
|
23
|
+
export declare function grantsSQL(): string;
|
package/dist/sql-space.js
CHANGED
|
@@ -25,7 +25,7 @@ export function currentSpaceSQL() {
|
|
|
25
25
|
-- default space in self-host (service role, anon key). Policies, the
|
|
26
26
|
-- insert trigger and the reorder RPC all read this ONE function.
|
|
27
27
|
create or replace function "smoodly_current_space"() returns uuid
|
|
28
|
-
language sql stable as $$
|
|
28
|
+
language sql stable set search_path = '' as $$
|
|
29
29
|
select coalesce((auth.jwt() ->> 'space_id')::uuid, ${DEFAULT_SPACE});
|
|
30
30
|
$$;`.trim();
|
|
31
31
|
}
|
|
@@ -90,7 +90,7 @@ alter table "spaces" enable row level security;
|
|
|
90
90
|
-- ── Claim readers. The two lookups run as definer so they can read the
|
|
91
91
|
-- service-role-only tables from inside a policy; search_path is pinned.
|
|
92
92
|
create or replace function "smoodly_key_kind"() returns text
|
|
93
|
-
language sql stable as $$
|
|
93
|
+
language sql stable set search_path = '' as $$
|
|
94
94
|
select auth.jwt() ->> 'kind';
|
|
95
95
|
$$;
|
|
96
96
|
|
|
@@ -107,12 +107,25 @@ language sql stable security definer set search_path = public as $$
|
|
|
107
107
|
select c."row_cap" from "spaces" c where c."space_id" = "smoodly_current_space"();
|
|
108
108
|
$$;
|
|
109
109
|
|
|
110
|
+
-- The presence gate (spec 2026-09-26 security pass §1): the caller is a
|
|
111
|
+
-- member of the space the Realtime topic names. auth.uid() is the session
|
|
112
|
+
-- token's subject — the anon key and a read key carry none, so a visitor
|
|
113
|
+
-- is refused at join. Definer, because members has no read policy for
|
|
114
|
+
-- anyone but a write key. No space claim is read: the same rule serves
|
|
115
|
+
-- the cloud once the content project accepts smoodly.io's tokens (OQ 32).
|
|
116
|
+
create or replace function "smoodly_presence_member"() returns boolean
|
|
117
|
+
language sql stable security definer set search_path = public as $$
|
|
118
|
+
select exists (select 1 from "members" m
|
|
119
|
+
where m."user_id" = auth.uid()
|
|
120
|
+
and realtime.topic() = 'space:' || m."space_id"::text);
|
|
121
|
+
$$;
|
|
122
|
+
|
|
110
123
|
-- ── Triggers. A key's claim wins over whatever the insert said, so an
|
|
111
124
|
-- adapter never has to know its space and cannot write into another.
|
|
112
125
|
-- No claim (service role, anon) leaves the given or default value —
|
|
113
126
|
-- that is how smoodly.io writes cloud-state rows for a chosen space.
|
|
114
127
|
create or replace function "smoodly_set_space"() returns trigger
|
|
115
|
-
language plpgsql as $$
|
|
128
|
+
language plpgsql set search_path = '' as $$
|
|
116
129
|
begin
|
|
117
130
|
if auth.jwt() ->> 'space_id' is not null then
|
|
118
131
|
new."space_id" := (auth.jwt() ->> 'space_id')::uuid;
|
|
@@ -199,6 +212,19 @@ $$;`.trim());
|
|
|
199
212
|
statements.push(`drop policy if exists "smoodly_read" on "smoodly_meta";\n` +
|
|
200
213
|
`create policy "smoodly_read" on "smoodly_meta" for select\n` +
|
|
201
214
|
` using ((select "smoodly_key_kind"()) = 'write' and not (select "smoodly_space_key_revoked"()));`);
|
|
215
|
+
// The editing-presence channel (spec 2026-09-26 concurrency §5; gated
|
|
216
|
+
// 2026-09-26 security pass §1): one private Realtime topic per space,
|
|
217
|
+
// `space:<id>`. A caller receives on and sends to a topic only when a
|
|
218
|
+
// members row of that space names their session's user — the browser
|
|
219
|
+
// authenticates the socket with the editor's session token, never with
|
|
220
|
+
// the public key alone. Identical in self-host and the cloud.
|
|
221
|
+
const member = `(select public."smoodly_presence_member"())`;
|
|
222
|
+
statements.push(`drop policy if exists "smoodly_presence_read" on "realtime"."messages";\n` +
|
|
223
|
+
`create policy "smoodly_presence_read" on "realtime"."messages" for select\n` +
|
|
224
|
+
` using (${member});`);
|
|
225
|
+
statements.push(`drop policy if exists "smoodly_presence_write" on "realtime"."messages";\n` +
|
|
226
|
+
`create policy "smoodly_presence_write" on "realtime"."messages" for insert\n` +
|
|
227
|
+
` with check (${member});`);
|
|
202
228
|
statements.push(storageSQL());
|
|
203
229
|
return statements.join("\n\n");
|
|
204
230
|
}
|
|
@@ -227,3 +253,15 @@ create policy "smoodly_media_write" on storage.objects for all
|
|
|
227
253
|
using (${write})
|
|
228
254
|
with check (${write});`.trim();
|
|
229
255
|
}
|
|
256
|
+
/** The three mutation RPCs are the stores' verbs, never a browser's (spec
|
|
257
|
+
* 2026-09-26 security pass §2): executable by the service role and by
|
|
258
|
+
* `authenticated` (the role a cloud write key carries), not by anon or
|
|
259
|
+
* public. `smoodly_current_space` is NOT here — the stores read it through
|
|
260
|
+
* rpc(). Last in the schema, after every definition exists. */
|
|
261
|
+
export function grantsSQL() {
|
|
262
|
+
return [
|
|
263
|
+
`revoke execute on function "smoodly_reorder"(text, uuid[]) from public, anon;`,
|
|
264
|
+
`revoke execute on function "smoodly_reorder_pages"(uuid[]) from public, anon;`,
|
|
265
|
+
`revoke execute on function "smoodly_rewrite_paths"(jsonb, jsonb) from public, anon;`,
|
|
266
|
+
].join("\n");
|
|
267
|
+
}
|
package/dist/sql.js
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
// locale (spec 2026-09-06 §7); a versioned collection reads its
|
|
12
12
|
// fields from the published snapshot.
|
|
13
13
|
import { BUILT_IN_ORDER_FIELDS } from "./collections.js";
|
|
14
|
-
import { currentSpaceSQL, SPACE_COL, spaceLayerSQL } from "./sql-space.js";
|
|
14
|
+
import { currentSpaceSQL, grantsSQL, SPACE_COL, spaceLayerSQL } from "./sql-space.js";
|
|
15
15
|
const snake = (s) => s.replace(/[A-Z]/g, (c) => "_" + c.toLowerCase());
|
|
16
16
|
const q = (s) => `"${s}"`;
|
|
17
17
|
// Casts are fine in views (unlike index expressions, which must be
|
|
@@ -111,7 +111,7 @@ create table if not exists "page_locales" (
|
|
|
111
111
|
);
|
|
112
112
|
|
|
113
113
|
-- Sibling reorder for pages: same shape as smoodly_reorder for entries.
|
|
114
|
-
create or replace function "smoodly_reorder_pages"(p_ids uuid[]) returns void language sql as $$
|
|
114
|
+
create or replace function "smoodly_reorder_pages"(p_ids uuid[]) returns void language sql set search_path = public as $$
|
|
115
115
|
update "pages" pg set "sort" = u.ord
|
|
116
116
|
from unnest(p_ids) with ordinality as u(id, ord)
|
|
117
117
|
where pg."id" = u.id
|
|
@@ -193,6 +193,17 @@ create table if not exists "entry_versions" (
|
|
|
193
193
|
create index if not exists "entry_versions_entry_locale_idx"
|
|
194
194
|
on "entry_versions" ("entry_id", "locale", "saved_at");
|
|
195
195
|
|
|
196
|
+
-- ── Draft pointers as foreign keys (schema v2, spec 2026-09-26 concurrency §1):
|
|
197
|
+
-- PostgREST embeds the draft version's saved_at through them, so every
|
|
198
|
+
-- locale row read carries the token. Set null on delete: discard moves
|
|
199
|
+
-- the pointer before it deletes the abandoned row, so nothing dangles.
|
|
200
|
+
alter table "page_locales" drop constraint if exists "page_locales_draft_version_fkey";
|
|
201
|
+
alter table "page_locales" add constraint "page_locales_draft_version_fkey"
|
|
202
|
+
foreign key ("draft_version_id") references "page_versions" ("id") on delete set null;
|
|
203
|
+
alter table "entry_locales" drop constraint if exists "entry_locales_draft_version_fkey";
|
|
204
|
+
alter table "entry_locales" add constraint "entry_locales_draft_version_fkey"
|
|
205
|
+
foreign key ("draft_version_id") references "entry_versions" ("id") on delete set null;
|
|
206
|
+
|
|
196
207
|
-- v1 reorder (DESIGN.md §3 Manual ordering): rewrite sort 1..N in ONE
|
|
197
208
|
-- statement. Deliberately does not touch updated_at — a drag must not
|
|
198
209
|
-- make the whole collection look freshly edited. Scoped to the current
|
|
@@ -200,7 +211,7 @@ create index if not exists "entry_versions_entry_locale_idx"
|
|
|
200
211
|
create or replace function "smoodly_reorder"(
|
|
201
212
|
p_collection text,
|
|
202
213
|
p_ids uuid[]
|
|
203
|
-
) returns void language sql as $$
|
|
214
|
+
) returns void language sql set search_path = public as $$
|
|
204
215
|
update "entries" e set "sort" = u.ord
|
|
205
216
|
from unnest(p_ids) with ordinality as u(id, ord)
|
|
206
217
|
where e."id" = u.id
|
|
@@ -269,7 +280,7 @@ create index if not exists "assets_space_created_idx"
|
|
|
269
280
|
-- function runs in the caller's transaction, so a primary-key collision
|
|
270
281
|
-- rolls the whole rewrite back. Inserted rows get their space from the
|
|
271
282
|
-- a_set_space trigger (the claim, or the default space).
|
|
272
|
-
create or replace function "smoodly_rewrite_paths"(p_targets jsonb, p_rows jsonb) returns void language sql as $$
|
|
283
|
+
create or replace function "smoodly_rewrite_paths"(p_targets jsonb, p_rows jsonb) returns void language sql set search_path = public as $$
|
|
273
284
|
delete from "paths" p
|
|
274
285
|
using jsonb_to_recordset(p_targets) as t(kind text, id uuid)
|
|
275
286
|
where p."kind" = t.kind and p."target_id" = t.id
|
|
@@ -307,5 +318,6 @@ alter table "assets" enable row level security;
|
|
|
307
318
|
alter table "smoodly_meta" enable row level security;
|
|
308
319
|
`.trim(),
|
|
309
320
|
spaceLayerSQL(),
|
|
321
|
+
grantsSQL(),
|
|
310
322
|
].join("\n\n");
|
|
311
323
|
}
|
package/dist/store.d.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { type RefEdge, type SectionLike } from "./refs.ts";
|
|
|
4
4
|
import type { Toolset } from "./toolset.ts";
|
|
5
5
|
import type { MemoryRefIndex } from "./ref-index.ts";
|
|
6
6
|
import { type PathIndex } from "./path-index.ts";
|
|
7
|
+
import { type DraftToken } from "./draft-token.ts";
|
|
7
8
|
import { type LocaleSet, type PagePathOptions, type PathTarget } from "./paths.ts";
|
|
8
9
|
export type PageLocale = {
|
|
9
10
|
/** The node's OWN segment in this locale; the URL is derived (paths.ts). */
|
|
@@ -11,6 +12,8 @@ export type PageLocale = {
|
|
|
11
12
|
title: string;
|
|
12
13
|
status: "draft" | "published";
|
|
13
14
|
draftVersionId: string | null;
|
|
15
|
+
/** The draft version's saved time as the store's own text — the token's second half (spec 2026-09-26 concurrency §1). Null with no pointer. */
|
|
16
|
+
draftSavedAt: string | null;
|
|
14
17
|
publishedVersionId: string | null;
|
|
15
18
|
updatedAt: number;
|
|
16
19
|
/** The editor's email behind the last write that touched this row; null for the seed and scripts (spec 2026-09-13 history §2). */
|
|
@@ -81,18 +84,18 @@ export interface PageStore {
|
|
|
81
84
|
}): Promise<PageRecord>;
|
|
82
85
|
/** A folder becomes a page: an empty draft version per present locale. */
|
|
83
86
|
setTemplate(id: string, template: string, by?: string): Promise<PageRecord>;
|
|
84
|
-
saveDraft(id: string, locale: string, tree: PageTree, by?: string): Promise<PageVersion>;
|
|
85
|
-
publish(id: string, locale: string, by?: string): Promise<PageRecord>;
|
|
87
|
+
saveDraft(id: string, locale: string, tree: PageTree, by?: string, expected?: DraftToken | null): Promise<PageVersion>;
|
|
88
|
+
publish(id: string, locale: string, by?: string, expected?: DraftToken | null): Promise<PageRecord>;
|
|
86
89
|
/** The draft goes back to what is live: the locale's draft pointer set to its
|
|
87
90
|
* published pointer and the abandoned draft row deleted (spec 2026-09-13 history §1).
|
|
88
91
|
* Refused when nothing is published in the locale (spec 2026-09-13 publish flow §4). */
|
|
89
|
-
discardDraft(id: string, locale: string, by?: string): Promise<PageRecord>;
|
|
92
|
+
discardDraft(id: string, locale: string, by?: string, expected?: DraftToken | null): Promise<PageRecord>;
|
|
90
93
|
/** Takes ONE locale off the site: status draft, both pointers kept so a later
|
|
91
94
|
* publish republishes the current draft (spec §5). */
|
|
92
95
|
unpublish(id: string, locale: string, by?: string): Promise<PageRecord>;
|
|
93
96
|
/** The version's tree becomes the draft through the draft rule (spec 2026-09-13 history §6); the
|
|
94
97
|
* published version restores as a discard. Refused when the version is not this page's in this locale. */
|
|
95
|
-
restore(id: string, locale: string, versionId: string, by?: string): Promise<PageRecord>;
|
|
98
|
+
restore(id: string, locale: string, versionId: string, by?: string, expected?: DraftToken | null): Promise<PageRecord>;
|
|
96
99
|
/** One stored version by id, whoever it belongs to — callers check `pageId` and `locale`. */
|
|
97
100
|
getVersion(versionId: string): Promise<PageVersion | null>;
|
|
98
101
|
getDraftTree(id: string, locale: string): Promise<PageTree | null>;
|
|
@@ -210,11 +213,11 @@ export declare class MemoryPageStore implements PageStore {
|
|
|
210
213
|
index?: number;
|
|
211
214
|
}): Promise<PageRecord>;
|
|
212
215
|
setTemplate(id: string, template: string, by?: string): Promise<PageRecord>;
|
|
213
|
-
saveDraft(id: string, locale: string, tree: PageTree, by?: string): Promise<PageVersion>;
|
|
214
|
-
publish(id: string, locale: string, by?: string): Promise<PageRecord>;
|
|
215
|
-
discardDraft(id: string, locale: string, by?: string): Promise<PageRecord>;
|
|
216
|
+
saveDraft(id: string, locale: string, tree: PageTree, by?: string, expected?: DraftToken | null): Promise<PageVersion>;
|
|
217
|
+
publish(id: string, locale: string, by?: string, expected?: DraftToken | null): Promise<PageRecord>;
|
|
218
|
+
discardDraft(id: string, locale: string, by?: string, expected?: DraftToken | null): Promise<PageRecord>;
|
|
216
219
|
unpublish(id: string, locale: string, by?: string): Promise<PageRecord>;
|
|
217
|
-
restore(id: string, locale: string, versionId: string, by?: string): Promise<PageRecord>;
|
|
220
|
+
restore(id: string, locale: string, versionId: string, by?: string, expected?: DraftToken | null): Promise<PageRecord>;
|
|
218
221
|
getVersion(versionId: string): Promise<PageVersion | null>;
|
|
219
222
|
getDraftTree(id: string, locale: string): Promise<PageTree | null>;
|
|
220
223
|
fieldsOf(versionIds: string[]): Promise<Record<string, Record<string, unknown>>>;
|