@batterai/strapi-plugin-canvas 0.1.0
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/CHANGELOG.md +5 -0
- package/LICENSE +16 -0
- package/README.md +131 -0
- package/dist/admin/appearance.d.ts +12 -0
- package/dist/admin/appearance.d.ts.map +1 -0
- package/dist/admin/appearance.js +44 -0
- package/dist/admin/appearance.js.map +1 -0
- package/dist/admin/block-packages.d.ts +4 -0
- package/dist/admin/block-packages.d.ts.map +1 -0
- package/dist/admin/block-packages.js +14 -0
- package/dist/admin/block-packages.js.map +1 -0
- package/dist/admin/client/content-model.d.ts +3 -0
- package/dist/admin/client/content-model.d.ts.map +1 -0
- package/dist/admin/client/content-model.js +76 -0
- package/dist/admin/client/content-model.js.map +1 -0
- package/dist/admin/client/host-services.d.ts +4 -0
- package/dist/admin/client/host-services.d.ts.map +1 -0
- package/dist/admin/client/host-services.js +141 -0
- package/dist/admin/client/host-services.js.map +1 -0
- package/dist/admin/index.d.ts +37 -0
- package/dist/admin/index.d.ts.map +1 -0
- package/dist/admin/index.js +34 -0
- package/dist/admin/index.js.map +1 -0
- package/dist/admin/pages/App.d.ts +4 -0
- package/dist/admin/pages/App.d.ts.map +1 -0
- package/dist/admin/pages/App.js +50 -0
- package/dist/admin/pages/App.js.map +1 -0
- package/dist/admin/translations.d.ts +4 -0
- package/dist/admin/translations.d.ts.map +1 -0
- package/dist/admin/translations.js +16 -0
- package/dist/admin/translations.js.map +1 -0
- package/dist/constants.d.ts +14 -0
- package/dist/constants.d.ts.map +1 -0
- package/dist/constants.js +18 -0
- package/dist/constants.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -0
- package/dist/server/bootstrap.d.ts +4 -0
- package/dist/server/bootstrap.d.ts.map +1 -0
- package/dist/server/bootstrap.js +37 -0
- package/dist/server/bootstrap.js.map +1 -0
- package/dist/server/config/index.d.ts +24 -0
- package/dist/server/config/index.d.ts.map +1 -0
- package/dist/server/config/index.js +33 -0
- package/dist/server/config/index.js.map +1 -0
- package/dist/server/content-types/canvas-page/schema.d.ts +45 -0
- package/dist/server/content-types/canvas-page/schema.d.ts.map +1 -0
- package/dist/server/content-types/canvas-page/schema.js +21 -0
- package/dist/server/content-types/canvas-page/schema.js.map +1 -0
- package/dist/server/content-types/canvas-site/schema.d.ts +41 -0
- package/dist/server/content-types/canvas-site/schema.d.ts.map +1 -0
- package/dist/server/content-types/canvas-site/schema.js +20 -0
- package/dist/server/content-types/canvas-site/schema.js.map +1 -0
- package/dist/server/content-types/index.d.ts +90 -0
- package/dist/server/content-types/index.d.ts.map +1 -0
- package/dist/server/content-types/index.js +7 -0
- package/dist/server/content-types/index.js.map +1 -0
- package/dist/server/controllers/canvas.d.ts +48 -0
- package/dist/server/controllers/canvas.d.ts.map +1 -0
- package/dist/server/controllers/canvas.js +409 -0
- package/dist/server/controllers/canvas.js.map +1 -0
- package/dist/server/controllers/index.d.ts +448 -0
- package/dist/server/controllers/index.d.ts.map +1 -0
- package/dist/server/controllers/index.js +5 -0
- package/dist/server/controllers/index.js.map +1 -0
- package/dist/server/index.d.ts +645 -0
- package/dist/server/index.d.ts.map +1 -0
- package/dist/server/index.js +21 -0
- package/dist/server/index.js.map +1 -0
- package/dist/server/license.d.ts +13 -0
- package/dist/server/license.d.ts.map +1 -0
- package/dist/server/license.js +24 -0
- package/dist/server/license.js.map +1 -0
- package/dist/server/policies/is-admin-authenticated.d.ts +2 -0
- package/dist/server/policies/is-admin-authenticated.d.ts.map +1 -0
- package/dist/server/policies/is-admin-authenticated.js +4 -0
- package/dist/server/policies/is-admin-authenticated.js.map +1 -0
- package/dist/server/register.d.ts +2 -0
- package/dist/server/register.d.ts.map +1 -0
- package/dist/server/register.js +4 -0
- package/dist/server/register.js.map +1 -0
- package/dist/server/routes/admin.d.ts +18 -0
- package/dist/server/routes/admin.d.ts.map +1 -0
- package/dist/server/routes/admin.js +38 -0
- package/dist/server/routes/admin.js.map +1 -0
- package/dist/server/routes/index.d.ts +31 -0
- package/dist/server/routes/index.d.ts.map +1 -0
- package/dist/server/routes/index.js +7 -0
- package/dist/server/routes/index.js.map +1 -0
- package/dist/server/routes/public.d.ts +13 -0
- package/dist/server/routes/public.d.ts.map +1 -0
- package/dist/server/routes/public.js +7 -0
- package/dist/server/routes/public.js.map +1 -0
- package/dist/server/services/collaboration-service.d.ts +24 -0
- package/dist/server/services/collaboration-service.d.ts.map +1 -0
- package/dist/server/services/collaboration-service.js +164 -0
- package/dist/server/services/collaboration-service.js.map +1 -0
- package/dist/server/services/document-command-service.d.ts +34 -0
- package/dist/server/services/document-command-service.d.ts.map +1 -0
- package/dist/server/services/document-command-service.js +418 -0
- package/dist/server/services/document-command-service.js.map +1 -0
- package/dist/server/services/document-service.d.ts +14 -0
- package/dist/server/services/document-service.d.ts.map +1 -0
- package/dist/server/services/document-service.js +144 -0
- package/dist/server/services/document-service.js.map +1 -0
- package/dist/server/services/index.d.ts +68 -0
- package/dist/server/services/index.d.ts.map +1 -0
- package/dist/server/services/index.js +28 -0
- package/dist/server/services/index.js.map +1 -0
- package/dist/server/services/page-doc-store.d.ts +59 -0
- package/dist/server/services/page-doc-store.d.ts.map +1 -0
- package/dist/server/services/page-doc-store.js +285 -0
- package/dist/server/services/page-doc-store.js.map +1 -0
- package/dist/server/services/site-store.d.ts +43 -0
- package/dist/server/services/site-store.d.ts.map +1 -0
- package/dist/server/services/site-store.js +212 -0
- package/dist/server/services/site-store.js.map +1 -0
- package/dist/server-cjs/index.js +17381 -0
- package/dist/server-cjs/package.json +3 -0
- package/package.json +77 -0
- package/src/admin/appearance.ts +56 -0
- package/src/admin/block-packages.ts +16 -0
- package/src/admin/client/content-model.ts +88 -0
- package/src/admin/client/host-services.ts +172 -0
- package/src/admin/index.tsx +56 -0
- package/src/admin/pages/App.tsx +56 -0
- package/src/admin/strapi-admin.d.ts +1 -0
- package/src/admin/translations.ts +23 -0
- package/src/constants.ts +21 -0
- package/src/index.ts +11 -0
- package/src/server/bootstrap.ts +45 -0
- package/src/server/config/index.ts +46 -0
- package/src/server/content-types/canvas-page/schema.ts +20 -0
- package/src/server/content-types/canvas-site/schema.ts +19 -0
- package/src/server/content-types/index.ts +7 -0
- package/src/server/controllers/canvas.ts +468 -0
- package/src/server/controllers/index.ts +5 -0
- package/src/server/index.ts +21 -0
- package/src/server/license.ts +42 -0
- package/src/server/policies/is-admin-authenticated.ts +3 -0
- package/src/server/register.ts +3 -0
- package/src/server/routes/admin.ts +40 -0
- package/src/server/routes/index.ts +7 -0
- package/src/server/routes/public.ts +6 -0
- package/src/server/services/collaboration-service.ts +180 -0
- package/src/server/services/document-command-service.ts +495 -0
- package/src/server/services/document-service.ts +162 -0
- package/src/server/services/index.ts +41 -0
- package/src/server/services/page-doc-store.ts +348 -0
- package/src/server/services/site-store.ts +243 -0
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import type { EditorShellBootstrap } from "@batterai/editor-shell";
|
|
2
|
+
|
|
3
|
+
type ContentType = EditorShellBootstrap["contentTypes"][number];
|
|
4
|
+
type FieldSchema = ContentType["fields"][number];
|
|
5
|
+
type ValueType = FieldSchema["valueType"];
|
|
6
|
+
|
|
7
|
+
export type BoundCollectionConfig = Record<string, string>;
|
|
8
|
+
export type CollectionReadMode = "draft" | "published";
|
|
9
|
+
|
|
10
|
+
const INTERNAL_API_UIDS = new Set([
|
|
11
|
+
"api::canvas-page.canvas-page",
|
|
12
|
+
"api::canvas-site.canvas-site",
|
|
13
|
+
"api::battery-media-asset.battery-media-asset",
|
|
14
|
+
]);
|
|
15
|
+
|
|
16
|
+
type StrapiContentType = {
|
|
17
|
+
kind?: string;
|
|
18
|
+
info?: { displayName?: string; pluralName?: string; singularName?: string };
|
|
19
|
+
attributes?: Record<string, Record<string, unknown>>;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export function createDocumentService(
|
|
23
|
+
strapi: any,
|
|
24
|
+
boundCollections: BoundCollectionConfig = {},
|
|
25
|
+
discoverCollections = true,
|
|
26
|
+
) {
|
|
27
|
+
function collectionBindings(): Record<string, string> {
|
|
28
|
+
if (!discoverCollections) return { ...boundCollections };
|
|
29
|
+
const bindings: Record<string, string> = {};
|
|
30
|
+
const explicitlyBoundUids = new Set(Object.values(boundCollections));
|
|
31
|
+
for (const [uid, schema] of Object.entries((strapi.contentTypes ?? {}) as Record<string, StrapiContentType>)) {
|
|
32
|
+
if (!uid.startsWith("api::") || INTERNAL_API_UIDS.has(uid) || explicitlyBoundUids.has(uid)) continue;
|
|
33
|
+
if (schema.kind !== "collectionType") continue;
|
|
34
|
+
const key = schema.info?.pluralName ?? schema.info?.singularName ?? uid.slice("api::".length).split(".")[0]!;
|
|
35
|
+
bindings[key] = uid;
|
|
36
|
+
}
|
|
37
|
+
return { ...bindings, ...boundCollections };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function schemaFor(uid: string): StrapiContentType | undefined {
|
|
41
|
+
return (strapi.contentTypes ?? {})[uid] as StrapiContentType | undefined;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function collectionKeyForUid(uid: string, bindings: Record<string, string>): string | undefined {
|
|
45
|
+
return Object.entries(bindings).find(([, candidate]) => candidate === uid)?.[0];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
collectionNames(): string[] {
|
|
50
|
+
return Object.keys(collectionBindings());
|
|
51
|
+
},
|
|
52
|
+
listContentTypes(): ContentType[] {
|
|
53
|
+
const bindings = collectionBindings();
|
|
54
|
+
return Object.entries(bindings).map(([key, uid]) => {
|
|
55
|
+
const schema = schemaFor(uid);
|
|
56
|
+
const fields = Object.entries(schema?.attributes ?? {}).map(([name, attribute]) =>
|
|
57
|
+
fieldSchema(name, attribute, bindings, collectionKeyForUid),
|
|
58
|
+
);
|
|
59
|
+
return {
|
|
60
|
+
id: key as never,
|
|
61
|
+
name: schema?.info?.displayName ?? schema?.info?.pluralName ?? key,
|
|
62
|
+
fields,
|
|
63
|
+
};
|
|
64
|
+
});
|
|
65
|
+
},
|
|
66
|
+
async listCollection(collection: string, options: { status?: CollectionReadMode } = {}): Promise<unknown[]> {
|
|
67
|
+
const uid = collectionBindings()[collection];
|
|
68
|
+
if (!uid) return [];
|
|
69
|
+
const documents = strapi.documents?.(uid);
|
|
70
|
+
if (documents?.findMany) {
|
|
71
|
+
return normalizeItems(await documents.findMany({ status: options.status ?? "draft", limit: 100, populate: "*" }));
|
|
72
|
+
}
|
|
73
|
+
return normalizeItems(await strapi.entityService.findMany(uid, { limit: 100, populate: "*" }));
|
|
74
|
+
},
|
|
75
|
+
async saveCollection(collection: string, items: unknown[]): Promise<void> {
|
|
76
|
+
const uid = collectionBindings()[collection];
|
|
77
|
+
if (!uid) return;
|
|
78
|
+
const documents = strapi.documents?.(uid);
|
|
79
|
+
if (!documents?.update) return;
|
|
80
|
+
const schema = schemaFor(uid);
|
|
81
|
+
for (const item of items) {
|
|
82
|
+
if (!item || typeof item !== "object") continue;
|
|
83
|
+
const documentId = (item as { documentId?: unknown; id?: unknown }).documentId;
|
|
84
|
+
if (typeof documentId !== "string") continue;
|
|
85
|
+
await documents.update({
|
|
86
|
+
documentId,
|
|
87
|
+
status: "draft",
|
|
88
|
+
data: strapiDataForItem(item as Record<string, unknown>, schema),
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function normalizeItems(items: unknown): unknown[] {
|
|
96
|
+
if (!Array.isArray(items)) return [];
|
|
97
|
+
return items.map((item) => {
|
|
98
|
+
if (!item || typeof item !== "object" || Array.isArray(item)) return item;
|
|
99
|
+
const record = item as Record<string, unknown>;
|
|
100
|
+
const identity = record.documentId ?? record.id;
|
|
101
|
+
return identity === undefined ? record : { ...record, id: String(identity) };
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function strapiDataForItem(item: Record<string, unknown>, schema: StrapiContentType | undefined): Record<string, unknown> {
|
|
106
|
+
const data: Record<string, unknown> = {};
|
|
107
|
+
const attributes = schema?.attributes ?? {};
|
|
108
|
+
for (const [name, attribute] of Object.entries(attributes)) {
|
|
109
|
+
if (!isWritableAttribute(name, attribute) || !(name in item)) continue;
|
|
110
|
+
data[name] = strapiValueForAttribute(item[name], attribute);
|
|
111
|
+
}
|
|
112
|
+
return data;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function isWritableAttribute(name: string, attribute: Record<string, unknown>): boolean {
|
|
116
|
+
if (attribute.private === true || attribute.writable === false) return false;
|
|
117
|
+
if (attribute.unstable_virtual === true) return false;
|
|
118
|
+
if (["id", "documentId", "createdAt", "updatedAt", "publishedAt", "createdBy", "updatedBy", "localizations"].includes(name)) {
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
return true;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function strapiValueForAttribute(value: unknown, attribute: Record<string, unknown>): unknown {
|
|
125
|
+
if (!Array.isArray(value)) return value;
|
|
126
|
+
const type = typeof attribute.type === "string" ? attribute.type : "json";
|
|
127
|
+
if (["text", "richtext", "blocks"].includes(type)) return value.filter((entry) => typeof entry === "string").join("\n\n");
|
|
128
|
+
if (["string", "email", "uid", "enumeration", "password"].includes(type)) return value.filter((entry) => typeof entry === "string").join("\n");
|
|
129
|
+
return value;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function fieldSchema(
|
|
133
|
+
name: string,
|
|
134
|
+
attribute: Record<string, unknown>,
|
|
135
|
+
bindings: Record<string, string>,
|
|
136
|
+
collectionKeyForUid: (uid: string, bindings: Record<string, string>) => string | undefined,
|
|
137
|
+
): FieldSchema {
|
|
138
|
+
const type = typeof attribute.type === "string" ? attribute.type : "json";
|
|
139
|
+
const relation = typeof attribute.relation === "string" ? attribute.relation : "";
|
|
140
|
+
const target = typeof attribute.target === "string" ? attribute.target : undefined;
|
|
141
|
+
const isMany = relation.toLowerCase().includes("many") && !relation.toLowerCase().startsWith("manytoone");
|
|
142
|
+
const valueType: ValueType = type === "relation" ? "reference"
|
|
143
|
+
: type === "media" ? "media"
|
|
144
|
+
: ["integer", "biginteger", "float", "decimal"].includes(type) ? "number"
|
|
145
|
+
: type === "boolean" ? "boolean"
|
|
146
|
+
: ["date", "datetime", "time", "timestamp"].includes(type) ? "date"
|
|
147
|
+
: ["richtext", "blocks"].includes(type) ? "richtext"
|
|
148
|
+
: ["string", "text", "email", "uid", "enumeration", "password"].includes(type) ? "string"
|
|
149
|
+
: "json";
|
|
150
|
+
return {
|
|
151
|
+
name: name as never,
|
|
152
|
+
label: name,
|
|
153
|
+
valueType,
|
|
154
|
+
cardinality: (isMany || attribute.multiple === true || attribute.repeatable === true) ? "many" : "one",
|
|
155
|
+
...(type === "relation" ? { isReference: true } : {}),
|
|
156
|
+
...(target && collectionKeyForUid(target, bindings)
|
|
157
|
+
? { refType: collectionKeyForUid(target, bindings) as never }
|
|
158
|
+
: {}),
|
|
159
|
+
...(attribute.localized === true ? { localized: true } : {}),
|
|
160
|
+
...(attribute.default !== undefined ? { defaultValue: attribute.default } : {}),
|
|
161
|
+
};
|
|
162
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { createDocumentService } from "./document-service";
|
|
2
|
+
import { createDocumentCommandService } from "./document-command-service";
|
|
3
|
+
import { createPageDocStore, createStrapiPageDocRepository } from "./page-doc-store";
|
|
4
|
+
import { createSiteStore } from "./site-store";
|
|
5
|
+
import { createStrapiCollaborationService } from "./collaboration-service";
|
|
6
|
+
|
|
7
|
+
export default {
|
|
8
|
+
collaboration({ strapi }: { strapi: any }) {
|
|
9
|
+
return createStrapiCollaborationService(strapi);
|
|
10
|
+
},
|
|
11
|
+
documentCommand({ strapi }: { strapi: any }) {
|
|
12
|
+
return createDocumentCommandService(strapi);
|
|
13
|
+
},
|
|
14
|
+
pageDocStore({ strapi }: { strapi: any }) {
|
|
15
|
+
return createPageDocStore(createStrapiPageDocRepository(strapi));
|
|
16
|
+
},
|
|
17
|
+
siteStore({ strapi }: { strapi: any }) {
|
|
18
|
+
const config = strapi.config.get("plugin::canvas") ?? {};
|
|
19
|
+
return createSiteStore(strapi, config.seedLibrary);
|
|
20
|
+
},
|
|
21
|
+
documentService({ strapi }: { strapi: any }) {
|
|
22
|
+
const config = strapi.config.get("plugin::canvas") ?? {};
|
|
23
|
+
return createDocumentService(strapi, config.boundCollections ?? {}, config.discoverCollections ?? true);
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export {
|
|
28
|
+
CanvasPageConflict,
|
|
29
|
+
CanvasPageValidationError,
|
|
30
|
+
createPageDocStore,
|
|
31
|
+
createStrapiPageDocRepository,
|
|
32
|
+
documentVersion,
|
|
33
|
+
makeCanvasPageDocument,
|
|
34
|
+
} from "./page-doc-store";
|
|
35
|
+
export {
|
|
36
|
+
createDocumentCommandService,
|
|
37
|
+
strapiCanvasConcurrency,
|
|
38
|
+
type CanvasDocumentCommandTransaction,
|
|
39
|
+
type CanvasDocumentCommandTransactionRunner,
|
|
40
|
+
} from "./document-command-service";
|
|
41
|
+
export { CanvasSiteConflict, createSiteStore, defaultHomePage, defaultSiteDocument } from "./site-store";
|
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { CANVAS_PAGE_UID } from "../../constants";
|
|
3
|
+
|
|
4
|
+
export type CanvasPageDocument<Page = unknown> = {
|
|
5
|
+
kind: "canvas-editor-page";
|
|
6
|
+
docVersion: number;
|
|
7
|
+
page: Page;
|
|
8
|
+
staged?: {
|
|
9
|
+
page: Page;
|
|
10
|
+
stagedAt: string;
|
|
11
|
+
sourceDocVersion: number;
|
|
12
|
+
};
|
|
13
|
+
published?: {
|
|
14
|
+
page: Page;
|
|
15
|
+
publishedAt: string;
|
|
16
|
+
sourceDocVersion: number;
|
|
17
|
+
};
|
|
18
|
+
updatedAt: string;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export type CanvasPageRow<Page = unknown> = {
|
|
22
|
+
id?: string | number;
|
|
23
|
+
documentId?: string;
|
|
24
|
+
pageKey: string;
|
|
25
|
+
docVersion: number;
|
|
26
|
+
latestVersion?: string | null;
|
|
27
|
+
document: CanvasPageDocument<Page>;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export class CanvasPageConflict extends Error {
|
|
31
|
+
readonly status = 409;
|
|
32
|
+
|
|
33
|
+
constructor(
|
|
34
|
+
readonly pageKey: string,
|
|
35
|
+
readonly expectedDocVersion: number,
|
|
36
|
+
readonly actualDocVersion: number | undefined,
|
|
37
|
+
) {
|
|
38
|
+
super(`Canvas page conflict for ${pageKey}: expected ${expectedDocVersion}, got ${actualDocVersion ?? "missing"}`);
|
|
39
|
+
this.name = "CanvasPageConflict";
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export class CanvasPageValidationError extends Error {
|
|
44
|
+
readonly status = 400;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export type PageDocRepository<Page = unknown> = {
|
|
48
|
+
list: () => Promise<CanvasPageRow<Page>[]>;
|
|
49
|
+
findByPageKey: (pageKey: string) => Promise<CanvasPageRow<Page> | undefined>;
|
|
50
|
+
insert: (row: CanvasPageRow<Page>) => Promise<CanvasPageRow<Page>>;
|
|
51
|
+
updateIfVersion: (
|
|
52
|
+
pageKey: string,
|
|
53
|
+
expectedDocVersion: number,
|
|
54
|
+
patch: Pick<CanvasPageRow<Page>, "docVersion" | "latestVersion" | "document">,
|
|
55
|
+
) => Promise<number>;
|
|
56
|
+
deleteIfVersion: (pageKey: string, expectedDocVersion: number) => Promise<number>;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
export function documentVersion(document: unknown): number {
|
|
60
|
+
if (!document || typeof document !== "object" || Array.isArray(document)) return 0;
|
|
61
|
+
const version = (document as { docVersion?: unknown }).docVersion;
|
|
62
|
+
return typeof version === "number" && Number.isInteger(version) && version >= 0 ? version : 0;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function latestVersion(document: unknown): string | null {
|
|
66
|
+
if (!document || typeof document !== "object" || Array.isArray(document)) return null;
|
|
67
|
+
const version = (document as { latestVersion?: unknown }).latestVersion;
|
|
68
|
+
return typeof version === "string" ? version : null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function makeCanvasPageDocument<Page>(
|
|
72
|
+
page: Page,
|
|
73
|
+
expectedDocVersion: number,
|
|
74
|
+
now = new Date(),
|
|
75
|
+
published?: CanvasPageDocument<Page>["published"],
|
|
76
|
+
staged?: CanvasPageDocument<Page>["staged"],
|
|
77
|
+
): CanvasPageDocument<Page> {
|
|
78
|
+
if (!Number.isInteger(expectedDocVersion) || expectedDocVersion < 0) {
|
|
79
|
+
throw new CanvasPageValidationError("expectedDocVersion must be a non-negative integer.");
|
|
80
|
+
}
|
|
81
|
+
return {
|
|
82
|
+
kind: "canvas-editor-page",
|
|
83
|
+
docVersion: expectedDocVersion + 1,
|
|
84
|
+
page,
|
|
85
|
+
...(staged ? { staged } : {}),
|
|
86
|
+
...(published ? { published } : {}),
|
|
87
|
+
updatedAt: now.toISOString(),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function createPageDocStore<Page = unknown>(
|
|
92
|
+
repository: PageDocRepository<Page>,
|
|
93
|
+
options: { now?: () => Date; createDocumentId?: () => string } = {},
|
|
94
|
+
) {
|
|
95
|
+
const now = options.now ?? (() => new Date());
|
|
96
|
+
const createDocumentId = options.createDocumentId ?? randomUUID;
|
|
97
|
+
|
|
98
|
+
return {
|
|
99
|
+
async list(): Promise<CanvasPageRow<Page>[]> {
|
|
100
|
+
return repository.list();
|
|
101
|
+
},
|
|
102
|
+
async read(pageKey: string): Promise<CanvasPageRow<Page> | undefined> {
|
|
103
|
+
return repository.findByPageKey(pageKey);
|
|
104
|
+
},
|
|
105
|
+
async save(pageKey: string, page: Page, expectedDocVersion: number): Promise<CanvasPageRow<Page>> {
|
|
106
|
+
const existing = await repository.findByPageKey(pageKey);
|
|
107
|
+
const actualDocVersion = existing?.docVersion ?? documentVersion(existing?.document);
|
|
108
|
+
if (existing && actualDocVersion !== expectedDocVersion) {
|
|
109
|
+
throw new CanvasPageConflict(pageKey, expectedDocVersion, actualDocVersion);
|
|
110
|
+
}
|
|
111
|
+
const document = makeCanvasPageDocument(
|
|
112
|
+
page,
|
|
113
|
+
expectedDocVersion,
|
|
114
|
+
now(),
|
|
115
|
+
existing?.document.published,
|
|
116
|
+
existing?.document.staged,
|
|
117
|
+
);
|
|
118
|
+
const patch = {
|
|
119
|
+
docVersion: document.docVersion,
|
|
120
|
+
latestVersion: latestVersion(page) ?? document.updatedAt,
|
|
121
|
+
document,
|
|
122
|
+
};
|
|
123
|
+
if (!existing) {
|
|
124
|
+
if (expectedDocVersion !== 0) {
|
|
125
|
+
throw new CanvasPageConflict(pageKey, expectedDocVersion, undefined);
|
|
126
|
+
}
|
|
127
|
+
try {
|
|
128
|
+
return await repository.insert({
|
|
129
|
+
pageKey,
|
|
130
|
+
documentId: createDocumentId(),
|
|
131
|
+
...patch,
|
|
132
|
+
});
|
|
133
|
+
} catch (error) {
|
|
134
|
+
if (isUniqueError(error)) throw new CanvasPageConflict(pageKey, expectedDocVersion, undefined);
|
|
135
|
+
throw error;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
const affected = await repository.updateIfVersion(pageKey, expectedDocVersion, patch);
|
|
139
|
+
if (affected !== 1) throw new CanvasPageConflict(pageKey, expectedDocVersion, undefined);
|
|
140
|
+
return {
|
|
141
|
+
...existing,
|
|
142
|
+
...patch,
|
|
143
|
+
};
|
|
144
|
+
},
|
|
145
|
+
async remove(pageKey: string, expectedDocVersion: number): Promise<void> {
|
|
146
|
+
const existing = await repository.findByPageKey(pageKey);
|
|
147
|
+
const actualDocVersion = existing?.docVersion ?? documentVersion(existing?.document);
|
|
148
|
+
if (!existing || actualDocVersion !== expectedDocVersion) {
|
|
149
|
+
throw new CanvasPageConflict(pageKey, expectedDocVersion, existing ? actualDocVersion : undefined);
|
|
150
|
+
}
|
|
151
|
+
const affected = await repository.deleteIfVersion(pageKey, expectedDocVersion);
|
|
152
|
+
if (affected !== 1) throw new CanvasPageConflict(pageKey, expectedDocVersion, actualDocVersion);
|
|
153
|
+
},
|
|
154
|
+
async publish(
|
|
155
|
+
pageKey: string,
|
|
156
|
+
expectedDocVersion: number,
|
|
157
|
+
publishedAt?: Date,
|
|
158
|
+
): Promise<CanvasPageRow<Page>> {
|
|
159
|
+
const existing = await repository.findByPageKey(pageKey);
|
|
160
|
+
const actualDocVersion = existing?.docVersion ?? documentVersion(existing?.document);
|
|
161
|
+
if (!existing || actualDocVersion !== expectedDocVersion) {
|
|
162
|
+
throw new CanvasPageConflict(pageKey, expectedDocVersion, existing ? actualDocVersion : undefined);
|
|
163
|
+
}
|
|
164
|
+
const timestamp = (publishedAt ?? now()).toISOString();
|
|
165
|
+
const document: CanvasPageDocument<Page> = {
|
|
166
|
+
...existing.document,
|
|
167
|
+
docVersion: expectedDocVersion + 1,
|
|
168
|
+
updatedAt: timestamp,
|
|
169
|
+
published: {
|
|
170
|
+
page: existing.document.page,
|
|
171
|
+
publishedAt: timestamp,
|
|
172
|
+
sourceDocVersion: expectedDocVersion,
|
|
173
|
+
},
|
|
174
|
+
};
|
|
175
|
+
const patch = {
|
|
176
|
+
docVersion: document.docVersion,
|
|
177
|
+
latestVersion: timestamp,
|
|
178
|
+
document,
|
|
179
|
+
};
|
|
180
|
+
const affected = await repository.updateIfVersion(pageKey, expectedDocVersion, patch);
|
|
181
|
+
if (affected !== 1) throw new CanvasPageConflict(pageKey, expectedDocVersion, actualDocVersion);
|
|
182
|
+
return { ...existing, ...patch };
|
|
183
|
+
},
|
|
184
|
+
async stage(pageKey: string, expectedDocVersion: number): Promise<CanvasPageRow<Page>> {
|
|
185
|
+
const existing = await repository.findByPageKey(pageKey);
|
|
186
|
+
const actualDocVersion = existing?.docVersion ?? documentVersion(existing?.document);
|
|
187
|
+
if (!existing || actualDocVersion !== expectedDocVersion) {
|
|
188
|
+
throw new CanvasPageConflict(pageKey, expectedDocVersion, existing ? actualDocVersion : undefined);
|
|
189
|
+
}
|
|
190
|
+
const timestamp = now().toISOString();
|
|
191
|
+
const document: CanvasPageDocument<Page> = {
|
|
192
|
+
...existing.document,
|
|
193
|
+
docVersion: expectedDocVersion + 1,
|
|
194
|
+
updatedAt: timestamp,
|
|
195
|
+
staged: {
|
|
196
|
+
page: structuredClone(existing.document.page),
|
|
197
|
+
stagedAt: timestamp,
|
|
198
|
+
sourceDocVersion: expectedDocVersion,
|
|
199
|
+
},
|
|
200
|
+
};
|
|
201
|
+
const patch = { docVersion: document.docVersion, latestVersion: timestamp, document };
|
|
202
|
+
const affected = await repository.updateIfVersion(pageKey, expectedDocVersion, patch);
|
|
203
|
+
if (affected !== 1) throw new CanvasPageConflict(pageKey, expectedDocVersion, actualDocVersion);
|
|
204
|
+
return { ...existing, ...patch };
|
|
205
|
+
},
|
|
206
|
+
async unstage(pageKey: string, expectedDocVersion: number): Promise<CanvasPageRow<Page>> {
|
|
207
|
+
const existing = await repository.findByPageKey(pageKey);
|
|
208
|
+
const actualDocVersion = existing?.docVersion ?? documentVersion(existing?.document);
|
|
209
|
+
if (!existing || actualDocVersion !== expectedDocVersion) {
|
|
210
|
+
throw new CanvasPageConflict(pageKey, expectedDocVersion, existing ? actualDocVersion : undefined);
|
|
211
|
+
}
|
|
212
|
+
const timestamp = now().toISOString();
|
|
213
|
+
const { staged: _staged, ...rest } = existing.document;
|
|
214
|
+
const document: CanvasPageDocument<Page> = {
|
|
215
|
+
...rest,
|
|
216
|
+
docVersion: expectedDocVersion + 1,
|
|
217
|
+
updatedAt: timestamp,
|
|
218
|
+
};
|
|
219
|
+
const patch = { docVersion: document.docVersion, latestVersion: timestamp, document };
|
|
220
|
+
const affected = await repository.updateIfVersion(pageKey, expectedDocVersion, patch);
|
|
221
|
+
if (affected !== 1) throw new CanvasPageConflict(pageKey, expectedDocVersion, actualDocVersion);
|
|
222
|
+
return { ...existing, ...patch };
|
|
223
|
+
},
|
|
224
|
+
async publishStaged(
|
|
225
|
+
pageKey: string,
|
|
226
|
+
expectedDocVersion: number,
|
|
227
|
+
publishedAt?: Date,
|
|
228
|
+
): Promise<CanvasPageRow<Page>> {
|
|
229
|
+
const existing = await repository.findByPageKey(pageKey);
|
|
230
|
+
const actualDocVersion = existing?.docVersion ?? documentVersion(existing?.document);
|
|
231
|
+
if (!existing || actualDocVersion !== expectedDocVersion) {
|
|
232
|
+
throw new CanvasPageConflict(pageKey, expectedDocVersion, existing ? actualDocVersion : undefined);
|
|
233
|
+
}
|
|
234
|
+
if (!existing.document.staged) {
|
|
235
|
+
throw new CanvasPageValidationError(`Canvas page ${pageKey} has no staged revision.`);
|
|
236
|
+
}
|
|
237
|
+
const timestamp = (publishedAt ?? now()).toISOString();
|
|
238
|
+
const { staged, ...rest } = existing.document;
|
|
239
|
+
const document: CanvasPageDocument<Page> = {
|
|
240
|
+
...rest,
|
|
241
|
+
docVersion: expectedDocVersion + 1,
|
|
242
|
+
updatedAt: timestamp,
|
|
243
|
+
published: {
|
|
244
|
+
page: structuredClone(staged.page),
|
|
245
|
+
publishedAt: timestamp,
|
|
246
|
+
sourceDocVersion: staged.sourceDocVersion,
|
|
247
|
+
},
|
|
248
|
+
};
|
|
249
|
+
const patch = { docVersion: document.docVersion, latestVersion: timestamp, document };
|
|
250
|
+
const affected = await repository.updateIfVersion(pageKey, expectedDocVersion, patch);
|
|
251
|
+
if (affected !== 1) throw new CanvasPageConflict(pageKey, expectedDocVersion, actualDocVersion);
|
|
252
|
+
return { ...existing, ...patch };
|
|
253
|
+
},
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function isUniqueError(error: unknown): boolean {
|
|
258
|
+
const text = String(error instanceof Error ? error.message : error);
|
|
259
|
+
return /unique|duplicate|constraint/i.test(text);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export function createStrapiPageDocRepository<Page = unknown>(
|
|
263
|
+
strapi: any,
|
|
264
|
+
transaction?: ((tableName: string) => any),
|
|
265
|
+
): PageDocRepository<Page> {
|
|
266
|
+
const metadata = strapi.db.metadata.get(CANVAS_PAGE_UID);
|
|
267
|
+
const tableName = metadata.tableName;
|
|
268
|
+
const columns = {
|
|
269
|
+
documentId: columnName(metadata, "documentId", "document_id"),
|
|
270
|
+
pageKey: columnName(metadata, "pageKey", "page_key"),
|
|
271
|
+
docVersion: columnName(metadata, "docVersion", "doc_version"),
|
|
272
|
+
latestVersion: columnName(metadata, "latestVersion", "latest_version"),
|
|
273
|
+
document: columnName(metadata, "document", "document"),
|
|
274
|
+
createdAt: columnName(metadata, "createdAt", "created_at"),
|
|
275
|
+
updatedAt: columnName(metadata, "updatedAt", "updated_at"),
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
function table() {
|
|
279
|
+
return transaction ? transaction(tableName) : strapi.db.connection(tableName);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function toRow(row: Record<string, unknown> | undefined): CanvasPageRow<Page> | undefined {
|
|
283
|
+
if (!row) return undefined;
|
|
284
|
+
return {
|
|
285
|
+
id: row.id as string | number | undefined,
|
|
286
|
+
documentId: row[columns.documentId] as string | undefined,
|
|
287
|
+
pageKey: String(row[columns.pageKey]),
|
|
288
|
+
docVersion: Number(row[columns.docVersion] ?? 0),
|
|
289
|
+
latestVersion: row[columns.latestVersion] as string | null | undefined,
|
|
290
|
+
document: parseJson(row[columns.document]) as CanvasPageDocument<Page>,
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
return {
|
|
295
|
+
async list() {
|
|
296
|
+
const rows = await table().select("*");
|
|
297
|
+
return rows.map((row: Record<string, unknown>) => toRow(row)!);
|
|
298
|
+
},
|
|
299
|
+
async findByPageKey(pageKey) {
|
|
300
|
+
const row = await table().where({ [columns.pageKey]: pageKey }).first();
|
|
301
|
+
return toRow(row);
|
|
302
|
+
},
|
|
303
|
+
async insert(row) {
|
|
304
|
+
const timestamp = new Date().toISOString();
|
|
305
|
+
await table().insert({
|
|
306
|
+
[columns.documentId]: row.documentId ?? randomUUID(),
|
|
307
|
+
[columns.pageKey]: row.pageKey,
|
|
308
|
+
[columns.docVersion]: row.docVersion,
|
|
309
|
+
[columns.latestVersion]: row.latestVersion ?? null,
|
|
310
|
+
[columns.document]: JSON.stringify(row.document),
|
|
311
|
+
[columns.createdAt]: timestamp,
|
|
312
|
+
[columns.updatedAt]: timestamp,
|
|
313
|
+
});
|
|
314
|
+
return (await this.findByPageKey(row.pageKey)) ?? row;
|
|
315
|
+
},
|
|
316
|
+
async updateIfVersion(pageKey, expectedDocVersion, patch) {
|
|
317
|
+
const timestamp = new Date().toISOString();
|
|
318
|
+
const result = await table()
|
|
319
|
+
.where({ [columns.pageKey]: pageKey, [columns.docVersion]: expectedDocVersion })
|
|
320
|
+
.update({
|
|
321
|
+
[columns.docVersion]: patch.docVersion,
|
|
322
|
+
[columns.latestVersion]: patch.latestVersion ?? null,
|
|
323
|
+
[columns.document]: JSON.stringify(patch.document),
|
|
324
|
+
[columns.updatedAt]: timestamp,
|
|
325
|
+
});
|
|
326
|
+
return Number(result);
|
|
327
|
+
},
|
|
328
|
+
async deleteIfVersion(pageKey, expectedDocVersion) {
|
|
329
|
+
const result = await table()
|
|
330
|
+
.where({ [columns.pageKey]: pageKey, [columns.docVersion]: expectedDocVersion })
|
|
331
|
+
.delete();
|
|
332
|
+
return Number(result);
|
|
333
|
+
},
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function columnName(metadata: any, attribute: string, fallback: string): string {
|
|
338
|
+
return metadata?.attributes?.[attribute]?.columnName ?? fallback;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function parseJson(value: unknown): unknown {
|
|
342
|
+
if (typeof value !== "string") return value;
|
|
343
|
+
try {
|
|
344
|
+
return JSON.parse(value);
|
|
345
|
+
} catch {
|
|
346
|
+
return value;
|
|
347
|
+
}
|
|
348
|
+
}
|