@btst/plugin-cms 1.0.0 → 1.2.0-beta.1

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 (76) hide show
  1. package/dist/api/getters.cjs +37 -25
  2. package/dist/api/getters.mjs +37 -25
  3. package/dist/api/index.cjs +1 -0
  4. package/dist/api/index.d.cts +17 -8
  5. package/dist/api/index.d.mts +17 -8
  6. package/dist/api/index.d.ts +17 -8
  7. package/dist/api/index.mjs +1 -1
  8. package/dist/api/mutations.cjs +46 -19
  9. package/dist/api/mutations.mjs +46 -20
  10. package/dist/api/operations.cjs +586 -177
  11. package/dist/api/operations.mjs +585 -178
  12. package/dist/api/plugin.cjs +56 -7
  13. package/dist/api/plugin.mjs +57 -8
  14. package/dist/api/publication.cjs +8 -0
  15. package/dist/api/publication.mjs +6 -0
  16. package/dist/client/components/forms/content-form.cjs +107 -1
  17. package/dist/client/components/forms/content-form.mjs +108 -2
  18. package/dist/client/components/pages/content-editor-page.internal.cjs +23 -5
  19. package/dist/client/components/pages/content-editor-page.internal.mjs +23 -5
  20. package/dist/client/components/pages/content-list-page.internal.cjs +3 -0
  21. package/dist/client/components/pages/content-list-page.internal.mjs +3 -0
  22. package/dist/client/components/pages/dashboard-page.internal.cjs +10 -30
  23. package/dist/client/components/pages/dashboard-page.internal.mjs +10 -30
  24. package/dist/client/components/revision-history.cjs +161 -0
  25. package/dist/client/components/revision-history.mjs +159 -0
  26. package/dist/client/components/shared/content-type-card.cjs +56 -0
  27. package/dist/client/components/shared/content-type-card.mjs +54 -0
  28. package/dist/client/components/shared/empty-state.cjs +18 -1
  29. package/dist/client/components/shared/empty-state.mjs +19 -2
  30. package/dist/client/hooks/cms-hooks.cjs +18 -2
  31. package/dist/client/hooks/cms-hooks.mjs +17 -3
  32. package/dist/client/hooks/index.cjs +2 -0
  33. package/dist/client/hooks/index.d.cts +306 -2
  34. package/dist/client/hooks/index.d.mts +306 -2
  35. package/dist/client/hooks/index.d.ts +306 -2
  36. package/dist/client/hooks/index.mjs +1 -1
  37. package/dist/client/index.d.cts +55 -2
  38. package/dist/client/index.d.mts +55 -2
  39. package/dist/client/index.d.ts +55 -2
  40. package/dist/db.cjs +25 -0
  41. package/dist/db.mjs +25 -0
  42. package/dist/permissions.cjs +2 -0
  43. package/dist/permissions.d.cts +1 -0
  44. package/dist/permissions.d.mts +1 -0
  45. package/dist/permissions.d.ts +1 -0
  46. package/dist/permissions.mjs +2 -0
  47. package/dist/query-keys.cjs +31 -1
  48. package/dist/query-keys.d.cts +1 -1
  49. package/dist/query-keys.d.mts +1 -1
  50. package/dist/query-keys.d.ts +1 -1
  51. package/dist/query-keys.mjs +31 -1
  52. package/dist/shared/{plugin-cms.W4Le8D91.d.mts → plugin-cms.DAAUZ41w.d.mts} +377 -144
  53. package/dist/shared/{plugin-cms.c-GeWvTf.d.cts → plugin-cms.DJ_293QU.d.cts} +377 -144
  54. package/dist/shared/{plugin-cms.DQLAoogy.d.ts → plugin-cms.DlUSv7wz.d.ts} +377 -144
  55. package/dist/types.d.cts +38 -2
  56. package/dist/types.d.mts +38 -2
  57. package/dist/types.d.ts +38 -2
  58. package/dist/ui/src/components/auto-form/config.cjs +2 -0
  59. package/dist/ui/src/components/auto-form/config.mjs +2 -0
  60. package/dist/ui/src/components/auto-form/fields/file.cjs +68 -0
  61. package/dist/ui/src/components/auto-form/fields/file.mjs +63 -0
  62. package/dist/ui/src/components/auto-form/fields/object.cjs +9 -2
  63. package/dist/ui/src/components/auto-form/fields/object.mjs +9 -2
  64. package/dist/ui/src/components/auto-form/helpers.cjs +9 -0
  65. package/dist/ui/src/components/auto-form/helpers.mjs +9 -0
  66. package/dist/ui/src/components/auto-form/index.cjs +29 -4
  67. package/dist/ui/src/components/auto-form/index.mjs +31 -6
  68. package/dist/ui/src/components/auto-form/stepped-auto-form.cjs +34 -29
  69. package/dist/ui/src/components/auto-form/stepped-auto-form.mjs +35 -30
  70. package/dist/ui/src/components/sheet.cjs +126 -0
  71. package/dist/ui/src/components/sheet.mjs +105 -0
  72. package/dist/ui/src/lib/form-visibility.cjs +20 -0
  73. package/dist/ui/src/lib/form-visibility.mjs +18 -0
  74. package/dist/ui/src/lib/schema-converter.cjs +101 -0
  75. package/dist/ui/src/lib/schema-converter.mjs +100 -1
  76. package/package.json +5 -4
@@ -6,7 +6,16 @@ import { createListContentQuerySchema, DEFAULT_MAX_PAGE_SIZE, contentReadOptions
6
6
  import { slugify } from '../utils.mjs';
7
7
  import { serializeContentType, getAllContentItems, getContentItemBySlug, applyContentReadOptions, serializeContentItemWithType, getContentItemsByRelationForType, validateContentReadOptions, CMSContentSelectionError } from './getters.mjs';
8
8
  import { syncRelations, extractRelationFields, isNewRelationValue, isExistingRelationValue } from './relations.mjs';
9
+ import { recordContentRevision } from './mutations.mjs';
10
+ import { isPublishedContent } from './publication.mjs';
9
11
 
12
+ const dispatchQueues = /* @__PURE__ */ new WeakMap();
13
+ function freezeCMSOperationData(value) {
14
+ if (value && typeof value === "object") {
15
+ Object.values(value).forEach(freezeCMSOperationData);
16
+ Object.freeze(value);
17
+ }
18
+ }
10
19
  function operationContentType(contentType) {
11
20
  return { ...contentType };
12
21
  }
@@ -55,13 +64,22 @@ const CMSContentItemParamsSchema = z.object({
55
64
  typeSlug: z.string().min(1),
56
65
  id: z.string().min(1)
57
66
  });
67
+ const PublishAtSchema = z.iso.datetime({ offset: true }).nullable().optional();
68
+ const CMSRevisionParamsSchema = CMSContentItemParamsSchema.extend({
69
+ revisionId: z.string().min(1)
70
+ });
71
+ const CMSRevisionsQuerySchema = z.object({
72
+ offset: z.coerce.number().int().min(0).default(0)
73
+ });
58
74
  const CMSCreateContentItemBodySchema = z.object({
59
75
  slug: z.string().min(1),
60
- data: CMSDataSchema
76
+ data: CMSDataSchema,
77
+ publishAt: PublishAtSchema
61
78
  });
62
79
  const CMSUpdateContentItemBodySchema = z.object({
63
80
  slug: z.string().min(1).optional(),
64
- data: CMSDataSchema.optional()
81
+ data: CMSDataSchema.optional(),
82
+ publishAt: PublishAtSchema
65
83
  });
66
84
  const ListContentTypesInputSchema = EmptyInputSchema;
67
85
  const GetContentTypeInputSchema = CMSContentTypeParamsSchema;
@@ -194,7 +212,7 @@ async function getRecordOrThrow(adapter, ensureSynced, typeSlug, id) {
194
212
  }
195
213
  return item;
196
214
  }
197
- function recordFacts(item) {
215
+ function recordFacts(item, view) {
198
216
  if (!item.contentType) {
199
217
  throw new CMSOperationError(
200
218
  500,
@@ -204,13 +222,14 @@ function recordFacts(item) {
204
222
  }
205
223
  return {
206
224
  contentType: item.contentType.slug,
225
+ ...view ? { view } : {},
207
226
  scope: "record",
208
227
  recordId: item.id,
209
228
  ...item.authorId ? { authorId: item.authorId } : {}
210
229
  };
211
230
  }
212
- function collectionFacts(contentType) {
213
- return { contentType, scope: "collection" };
231
+ function collectionFacts(contentType, view) {
232
+ return { contentType, scope: "collection", ...view ? { view } : {} };
214
233
  }
215
234
  function assertRecordFacts(item, facts) {
216
235
  if (!facts.recordId || item.id !== facts.recordId || item.contentType?.slug !== facts.contentType || (item.authorId ?? void 0) !== facts.authorId) {
@@ -258,7 +277,7 @@ function staleRecordError() {
258
277
  );
259
278
  }
260
279
  function assertRecordSnapshot(current, expected) {
261
- if (current.id !== expected.id || current.contentTypeId !== expected.contentTypeId || current.contentType?.slug !== expected.contentType?.slug || (current.authorId ?? void 0) !== (expected.authorId ?? void 0) || current.slug !== expected.slug || current.data !== expected.data || current.updatedAt.getTime() !== expected.updatedAt.getTime()) {
280
+ if (current.id !== expected.id || current.contentTypeId !== expected.contentTypeId || current.contentType?.slug !== expected.contentType?.slug || (current.authorId ?? void 0) !== (expected.authorId ?? void 0) || current.slug !== expected.slug || current.data !== expected.data || current.publishAt?.getTime() !== expected.publishAt?.getTime() || current.updatedAt.getTime() !== expected.updatedAt.getTime()) {
262
281
  throw staleRecordError();
263
282
  }
264
283
  }
@@ -399,8 +418,10 @@ async function applyRelationWritePlan(adapter, plan) {
399
418
  if (existing) throw staleRecordError();
400
419
  await adapter.create({
401
420
  model: "contentItem",
402
- data: item
421
+ data: item,
422
+ forceAllowId: true
403
423
  });
424
+ await recordContentRevision(adapter, item, item.authorId);
404
425
  }
405
426
  }
406
427
  function relationFilterSnapshot(sourceContentType, fieldName, relation, targetContentType) {
@@ -636,6 +657,7 @@ async function deriveInverseSourceAuthorization(adapter, ensureSynced, targetTyp
636
657
  }
637
658
  function createCMSOperations(adapter, options) {
638
659
  const { ensureSynced, getContentTypesWithCounts, hooks } = options;
660
+ const publicationFor = (typeSlug, view) => view === "content" ? options.contentTypes?.find((type) => type.slug === typeSlug)?.publication : void 0;
639
661
  const populatedRelationSnapshots = /* @__PURE__ */ new WeakMap();
640
662
  const relationWriteAuthorizationSnapshots = /* @__PURE__ */ new WeakMap();
641
663
  const relationFilterAuthorizationSnapshots = /* @__PURE__ */ new WeakMap();
@@ -887,7 +909,10 @@ function createCMSOperations(adapter, options) {
887
909
  input.typeSlug
888
910
  );
889
911
  if (!input.query.slug) {
890
- return collectionFacts(contentType.slug);
912
+ return collectionFacts(
913
+ contentType.slug,
914
+ publicationFor(contentType.slug, input.query.view) ? "content" : void 0
915
+ );
891
916
  }
892
917
  const item = await getContentItemBySlug(
893
918
  adapter,
@@ -896,6 +921,7 @@ function createCMSOperations(adapter, options) {
896
921
  );
897
922
  const facts = {
898
923
  contentType: contentType.slug,
924
+ ...publicationFor(contentType.slug, input.query.view) ? { view: "content" } : {},
899
925
  scope: "record",
900
926
  ...item ? {
901
927
  recordId: item.id,
@@ -914,11 +940,16 @@ function createCMSOperations(adapter, options) {
914
940
  input.query
915
941
  );
916
942
  }
917
- const result = await getAllContentItems(adapter, facts.contentType, {
918
- ...input.query,
919
- view: void 0,
920
- fields: void 0
921
- });
943
+ const result = await getAllContentItems(
944
+ adapter,
945
+ facts.contentType,
946
+ {
947
+ ...input.query,
948
+ view: void 0,
949
+ fields: void 0
950
+ },
951
+ publicationFor(facts.contentType, input.query.view)
952
+ );
922
953
  if (facts.scope === "record") {
923
954
  if (!facts.recordId) {
924
955
  if (result.items.length > 0) throw staleRecordError();
@@ -956,7 +987,8 @@ function createCMSOperations(adapter, options) {
956
987
  input: GetContentItemInputSchema,
957
988
  permission: cmsPermissions.record.read,
958
989
  facts: async ({ input }) => recordFacts(
959
- await getRecordOrThrow(adapter, ensureSynced, input.typeSlug, input.id)
990
+ await getRecordOrThrow(adapter, ensureSynced, input.typeSlug, input.id),
991
+ publicationFor(input.typeSlug, input.view) ? "content" : void 0
960
992
  ),
961
993
  additionalPermissions: ({ facts }) => [
962
994
  cmsPermissions.contentType.read({ contentType: facts.contentType })
@@ -969,6 +1001,14 @@ function createCMSOperations(adapter, options) {
969
1001
  facts.recordId ?? ""
970
1002
  );
971
1003
  assertRecordFacts(item, facts);
1004
+ const publication = publicationFor(facts.contentType, input.view);
1005
+ if (publication && !isPublishedContent(item, publication)) {
1006
+ throw new CMSOperationError(
1007
+ 404,
1008
+ "Content item not found",
1009
+ "RECORD_NOT_FOUND"
1010
+ );
1011
+ }
972
1012
  validateOperationContentReadOptions(item.contentType, input);
973
1013
  return applyContentReadOptions(
974
1014
  operationContentItem(serializeContentItemWithType(item)),
@@ -1093,12 +1133,14 @@ function createCMSOperations(adapter, options) {
1093
1133
  contentTypeId: contentType.id,
1094
1134
  slug,
1095
1135
  data: JSON.stringify(validatedData),
1136
+ publishAt: context.input.body.publishAt ? new Date(context.input.body.publishAt) : null,
1096
1137
  ...context.identity ? { authorId: context.identity.id } : {},
1097
1138
  createdAt: /* @__PURE__ */ new Date(),
1098
1139
  updatedAt: /* @__PURE__ */ new Date()
1099
1140
  }
1100
1141
  });
1101
1142
  await syncRelations(tx, item.id, relationPlan.relationIds);
1143
+ await recordContentRevision(tx, item, context.identity?.id);
1102
1144
  const joined = await getRecordOrThrow(
1103
1145
  tx,
1104
1146
  ensureSynced,
@@ -1120,68 +1162,103 @@ function createCMSOperations(adapter, options) {
1120
1162
  await notifyError(error, "create", createContext(context));
1121
1163
  }
1122
1164
  });
1123
- const updateContentItem = defineOperation({
1124
- input: UpdateContentItemInputSchema,
1125
- permission: cmsPermissions.record.update,
1126
- facts: async ({ input }) => {
1127
- const item = await getRecordOrThrow(
1128
- adapter,
1129
- ensureSynced,
1130
- input.typeSlug,
1131
- input.id
1165
+ const executeUpdateContentItem = async (context, replaceData = false) => {
1166
+ const lifecycleContext = updateContext(context);
1167
+ const contentType = await getContentTypeOrThrow(
1168
+ adapter,
1169
+ ensureSynced,
1170
+ context.facts.contentType
1171
+ );
1172
+ const rawSlug = context.input.body.slug;
1173
+ const slug = rawSlug ? slugify(rawSlug) : void 0;
1174
+ if (rawSlug && !slug) {
1175
+ throw new CMSOperationError(
1176
+ 400,
1177
+ "Invalid slug: must contain at least one alphanumeric character",
1178
+ "INVALID_SLUG"
1132
1179
  );
1133
- const facts = recordFacts(item);
1134
- return {
1135
- contentType: facts.contentType,
1136
- recordId: facts.recordId ?? input.id,
1137
- ...facts.authorId ? { authorId: facts.authorId } : {}
1138
- };
1139
- },
1140
- additionalPermissions: async ({ input, facts }) => {
1141
- const contentTypePermission = cmsPermissions.contentType.read({
1142
- contentType: facts.contentType
1180
+ }
1181
+ const bodyData = context.input.body.data;
1182
+ const relationAuthorization = bodyData ? await recheckRelationWriteAuthorization(
1183
+ context.input,
1184
+ contentType.slug,
1185
+ bodyData
1186
+ ) : void 0;
1187
+ const authorizedRecord = await getRecordOrThrow(
1188
+ adapter,
1189
+ ensureSynced,
1190
+ context.facts.contentType,
1191
+ context.facts.recordId
1192
+ );
1193
+ assertRecordFacts(authorizedRecord, context.facts);
1194
+ if (slug && slug !== authorizedRecord.slug) {
1195
+ const duplicate = await adapter.findOne({
1196
+ model: "contentItem",
1197
+ where: [
1198
+ {
1199
+ field: "contentTypeId",
1200
+ value: contentType.id,
1201
+ operator: "eq"
1202
+ },
1203
+ { field: "slug", value: slug, operator: "eq" }
1204
+ ]
1143
1205
  });
1144
- if (!input.body.data) return [contentTypePermission];
1145
- const authorization = await deriveRelationWriteAuthorization(
1146
- adapter,
1147
- await getContentTypeOrThrow(adapter, ensureSynced, facts.contentType),
1148
- input.body.data,
1149
- ensureSynced
1150
- );
1151
- relationWriteAuthorizationSnapshots.set(input, authorization);
1152
- return [contentTypePermission, ...authorization.permissions];
1153
- },
1154
- execute: async (context) => {
1155
- const lifecycleContext = updateContext(context);
1156
- const contentType = await getContentTypeOrThrow(
1157
- adapter,
1158
- ensureSynced,
1159
- context.facts.contentType
1160
- );
1161
- const rawSlug = context.input.body.slug;
1162
- const slug = rawSlug ? slugify(rawSlug) : void 0;
1163
- if (rawSlug && !slug) {
1206
+ if (duplicate) {
1164
1207
  throw new CMSOperationError(
1165
- 400,
1166
- "Invalid slug: must contain at least one alphanumeric character",
1167
- "INVALID_SLUG"
1208
+ 409,
1209
+ "Content item with this slug already exists",
1210
+ "DUPLICATE_SLUG"
1168
1211
  );
1169
1212
  }
1170
- const bodyData = context.input.body.data;
1171
- const relationAuthorization = bodyData ? await recheckRelationWriteAuthorization(
1172
- context.input,
1173
- contentType.slug,
1174
- bodyData
1175
- ) : void 0;
1176
- const authorizedRecord = await getRecordOrThrow(
1177
- adapter,
1213
+ }
1214
+ const relationPlan = bodyData ? await planRelationsInData(
1215
+ adapter,
1216
+ contentType,
1217
+ bodyData,
1218
+ ensureSynced,
1219
+ context.identity?.id
1220
+ ) : void 0;
1221
+ const currentData = authorizedRecord.data ? JSON.parse(authorizedRecord.data) : {};
1222
+ const validation = getContentTypeZodSchema(contentType).safeParse({
1223
+ ...replaceData ? {} : currentData,
1224
+ ...relationPlan?.processedData ?? {}
1225
+ });
1226
+ if (!validation.success) {
1227
+ throw new CMSOperationError(
1228
+ 400,
1229
+ "Validation failed",
1230
+ "RECORD_VALIDATION_FAILED",
1231
+ validation.error.issues
1232
+ );
1233
+ }
1234
+ const mergedData = validation.data;
1235
+ await runBeforeHook(
1236
+ () => hooks?.onBeforeUpdateContent?.(
1237
+ context.facts.recordId,
1238
+ mergedData,
1239
+ lifecycleContext
1240
+ ),
1241
+ "Update operation denied"
1242
+ );
1243
+ return adapter.transaction(async (tx) => {
1244
+ if (bodyData && relationAuthorization) {
1245
+ await assertCurrentRelationWriteAuthorization(
1246
+ tx,
1247
+ relationAuthorization,
1248
+ contentType.slug,
1249
+ bodyData
1250
+ );
1251
+ }
1252
+ const current = await getRecordOrThrow(
1253
+ tx,
1178
1254
  ensureSynced,
1179
1255
  context.facts.contentType,
1180
1256
  context.facts.recordId
1181
1257
  );
1182
- assertRecordFacts(authorizedRecord, context.facts);
1183
- if (slug && slug !== authorizedRecord.slug) {
1184
- const duplicate = await adapter.findOne({
1258
+ assertRecordFacts(current, context.facts);
1259
+ assertRecordSnapshot(current, authorizedRecord);
1260
+ if (slug && slug !== current.slug) {
1261
+ const duplicate = await tx.findOne({
1185
1262
  model: "contentItem",
1186
1263
  where: [
1187
1264
  {
@@ -1200,132 +1277,453 @@ function createCMSOperations(adapter, options) {
1200
1277
  );
1201
1278
  }
1202
1279
  }
1203
- const relationPlan = bodyData ? await planRelationsInData(
1204
- adapter,
1205
- contentType,
1206
- bodyData,
1207
- ensureSynced,
1208
- context.identity?.id
1209
- ) : void 0;
1210
- const currentData = authorizedRecord.data ? JSON.parse(authorizedRecord.data) : {};
1211
- const validation = getContentTypeZodSchema(contentType).safeParse({
1212
- ...currentData,
1213
- ...relationPlan?.processedData ?? {}
1280
+ if (relationPlan) await applyRelationWritePlan(tx, relationPlan);
1281
+ const update = {
1282
+ updatedAt: new Date(
1283
+ Math.max(Date.now(), current.updatedAt.getTime() + 1)
1284
+ )
1285
+ };
1286
+ if (context.input.body.publishAt !== void 0) {
1287
+ update.publishAt = context.input.body.publishAt ? new Date(context.input.body.publishAt) : null;
1288
+ }
1289
+ if (slug) update.slug = slug;
1290
+ if (bodyData) update.data = JSON.stringify(mergedData);
1291
+ const matched = await tx.updateMany({
1292
+ model: "contentItem",
1293
+ where: [
1294
+ {
1295
+ field: "id",
1296
+ value: context.facts.recordId,
1297
+ operator: "eq"
1298
+ },
1299
+ {
1300
+ field: "contentTypeId",
1301
+ value: contentType.id,
1302
+ operator: "eq"
1303
+ },
1304
+ {
1305
+ field: "authorId",
1306
+ value: context.facts.authorId ?? null,
1307
+ operator: "eq"
1308
+ },
1309
+ {
1310
+ field: "updatedAt",
1311
+ value: current.updatedAt,
1312
+ operator: "eq"
1313
+ }
1314
+ ],
1315
+ update
1214
1316
  });
1215
- if (!validation.success) {
1216
- throw new CMSOperationError(
1217
- 400,
1218
- "Validation failed",
1219
- "RECORD_VALIDATION_FAILED",
1220
- validation.error.issues
1221
- );
1317
+ if (!didUpdateContentItem(matched, context.facts.recordId)) {
1318
+ throw staleRecordError();
1222
1319
  }
1223
- const mergedData = validation.data;
1224
- await runBeforeHook(
1225
- () => hooks?.onBeforeUpdateContent?.(
1320
+ if (replaceData) {
1321
+ await tx.deleteMany({
1322
+ model: "contentRelation",
1323
+ where: [{ field: "sourceId", value: current.id, operator: "eq" }]
1324
+ });
1325
+ }
1326
+ if (relationPlan) {
1327
+ await syncRelations(
1328
+ tx,
1226
1329
  context.facts.recordId,
1227
- mergedData,
1228
- lifecycleContext
1229
- ),
1230
- "Update operation denied"
1330
+ relationPlan.relationIds
1331
+ );
1332
+ }
1333
+ const updated = await getRecordOrThrow(
1334
+ tx,
1335
+ ensureSynced,
1336
+ context.facts.contentType,
1337
+ context.facts.recordId
1231
1338
  );
1232
- return adapter.transaction(async (tx) => {
1233
- if (bodyData && relationAuthorization) {
1234
- await assertCurrentRelationWriteAuthorization(
1235
- tx,
1236
- relationAuthorization,
1237
- contentType.slug,
1238
- bodyData
1339
+ await recordContentRevision(tx, updated, context.identity?.id);
1340
+ return operationContentItem(serializeContentItemWithType(updated));
1341
+ });
1342
+ };
1343
+ const authorizeUpdateRelations = async ({
1344
+ input,
1345
+ facts
1346
+ }) => {
1347
+ const contentTypePermission = cmsPermissions.contentType.read({
1348
+ contentType: facts.contentType
1349
+ });
1350
+ if (!input.body.data) return [contentTypePermission];
1351
+ const authorization = await deriveRelationWriteAuthorization(
1352
+ adapter,
1353
+ await getContentTypeOrThrow(adapter, ensureSynced, facts.contentType),
1354
+ input.body.data,
1355
+ ensureSynced
1356
+ );
1357
+ relationWriteAuthorizationSnapshots.set(input, authorization);
1358
+ return [contentTypePermission, ...authorization.permissions];
1359
+ };
1360
+ const updateFacts = async ({
1361
+ input
1362
+ }) => {
1363
+ const item = await getRecordOrThrow(
1364
+ adapter,
1365
+ ensureSynced,
1366
+ input.typeSlug,
1367
+ input.id
1368
+ );
1369
+ const facts = recordFacts(item);
1370
+ return {
1371
+ contentType: facts.contentType,
1372
+ recordId: facts.recordId ?? input.id,
1373
+ ...facts.authorId ? { authorId: facts.authorId } : {}
1374
+ };
1375
+ };
1376
+ const afterUpdateContentItem = async (context) => {
1377
+ const base = updateContext(context);
1378
+ const afterUpdateContent = hooks?.onAfterUpdateContent;
1379
+ await afterUpdateContent?.(
1380
+ context.result,
1381
+ Object.freeze({ ...base, result: context.result })
1382
+ );
1383
+ };
1384
+ const updateContentItem = defineOperation({
1385
+ input: UpdateContentItemInputSchema,
1386
+ permission: cmsPermissions.record.update,
1387
+ facts: updateFacts,
1388
+ additionalPermissions: authorizeUpdateRelations,
1389
+ execute: (context) => executeUpdateContentItem(context),
1390
+ after: afterUpdateContentItem,
1391
+ onError: async ({ error, ...context }) => {
1392
+ await notifyError(error, "update", updateContext(context));
1393
+ }
1394
+ });
1395
+ const dispatchDueContent = defineOperation({
1396
+ input: z.object({
1397
+ limit: z.number().int().min(1).max(100).default(20),
1398
+ after: z.string().min(1).optional()
1399
+ }),
1400
+ permission: cmsPermissions.contentType.read,
1401
+ facts: async ({ request }) => {
1402
+ if (request)
1403
+ throw new CMSOperationError(
1404
+ 403,
1405
+ "Publication dispatch requires trusted execution",
1406
+ "TRUSTED_OPERATION_REQUIRED"
1407
+ );
1408
+ if (adapter.id !== "memory" && typeof adapter.options?.adapterConfig.transaction !== "function") {
1409
+ throw new CMSOperationError(
1410
+ 500,
1411
+ "Publication requires an adapter with isolated transaction support",
1412
+ "ATOMIC_TRANSACTION_REQUIRED"
1413
+ );
1414
+ }
1415
+ await ensureSynced();
1416
+ return {};
1417
+ },
1418
+ execute: async ({ input }) => {
1419
+ const run = async () => {
1420
+ const result = {
1421
+ scanned: 0,
1422
+ published: [],
1423
+ skipped: [],
1424
+ failures: [],
1425
+ nextCursor: null
1426
+ };
1427
+ const now = /* @__PURE__ */ new Date();
1428
+ const types = /* @__PURE__ */ new Map();
1429
+ for (const definition of options.contentTypes ?? []) {
1430
+ if (!definition.publication) continue;
1431
+ const type = await getContentTypeOrThrow(
1432
+ adapter,
1433
+ ensureSynced,
1434
+ definition.slug
1239
1435
  );
1436
+ types.set(type.id, { type, policy: definition.publication });
1240
1437
  }
1241
- const current = await getRecordOrThrow(
1242
- tx,
1243
- ensureSynced,
1244
- context.facts.contentType,
1245
- context.facts.recordId
1246
- );
1247
- assertRecordFacts(current, context.facts);
1248
- assertRecordSnapshot(current, authorizedRecord);
1249
- if (slug && slug !== current.slug) {
1250
- const duplicate = await tx.findOne({
1438
+ if (!types.size) return result;
1439
+ const dueWhere = [
1440
+ {
1441
+ field: "contentTypeId",
1442
+ value: [...types.keys()],
1443
+ operator: "in"
1444
+ },
1445
+ { field: "publishAt", value: null, operator: "ne" },
1446
+ { field: "publishAt", value: now, operator: "lte" }
1447
+ ];
1448
+ const memory = adapter.id === "memory" || adapter.options?.adapterConfig.adapterId === "memory";
1449
+ let offset = 0;
1450
+ if (memory && input.after) {
1451
+ let upper = await adapter.count({
1251
1452
  model: "contentItem",
1252
- where: [
1253
- {
1254
- field: "contentTypeId",
1255
- value: contentType.id,
1256
- operator: "eq"
1257
- },
1258
- { field: "slug", value: slug, operator: "eq" }
1259
- ]
1453
+ where: dueWhere
1260
1454
  });
1261
- if (duplicate) {
1262
- throw new CMSOperationError(
1263
- 409,
1264
- "Content item with this slug already exists",
1265
- "DUPLICATE_SLUG"
1266
- );
1455
+ while (offset < upper) {
1456
+ const middle = Math.floor((offset + upper) / 2);
1457
+ const [probe] = await adapter.findMany({
1458
+ model: "contentItem",
1459
+ where: dueWhere,
1460
+ select: ["id"],
1461
+ sortBy: { field: "id", direction: "asc" },
1462
+ limit: 1,
1463
+ offset: middle
1464
+ });
1465
+ if (probe && probe.id.localeCompare(input.after) <= 0)
1466
+ offset = middle + 1;
1467
+ else upper = middle;
1267
1468
  }
1268
1469
  }
1269
- if (relationPlan) await applyRelationWritePlan(tx, relationPlan);
1270
- const update = { updatedAt: /* @__PURE__ */ new Date() };
1271
- if (slug) update.slug = slug;
1272
- if (bodyData) update.data = JSON.stringify(mergedData);
1273
- const matched = await tx.updateMany({
1470
+ const candidates = await adapter.findMany({
1274
1471
  model: "contentItem",
1275
1472
  where: [
1276
- {
1277
- field: "id",
1278
- value: context.facts.recordId,
1279
- operator: "eq"
1280
- },
1281
- {
1282
- field: "contentTypeId",
1283
- value: contentType.id,
1284
- operator: "eq"
1285
- },
1286
- {
1287
- field: "authorId",
1288
- value: context.facts.authorId ?? null,
1289
- operator: "eq"
1290
- },
1291
- {
1292
- field: "updatedAt",
1293
- value: current.updatedAt,
1294
- operator: "eq"
1295
- }
1473
+ ...dueWhere,
1474
+ ...!memory && input.after ? [{ field: "id", value: input.after, operator: "gt" }] : []
1296
1475
  ],
1297
- update
1476
+ limit: input.limit,
1477
+ offset,
1478
+ sortBy: { field: "id", direction: "asc" }
1298
1479
  });
1299
- if (!didUpdateContentItem(matched, context.facts.recordId)) {
1300
- throw staleRecordError();
1301
- }
1302
- if (relationPlan) {
1303
- await syncRelations(
1304
- tx,
1305
- context.facts.recordId,
1306
- relationPlan.relationIds
1307
- );
1480
+ result.nextCursor = candidates.length === input.limit ? candidates.at(-1).id : null;
1481
+ for (const candidate of candidates) {
1482
+ const { type, policy } = types.get(candidate.contentTypeId);
1483
+ result.scanned += 1;
1484
+ let committed = false;
1485
+ let lifecycle;
1486
+ try {
1487
+ if (!candidate.publishAt || candidate.publishAt > now || JSON.parse(candidate.data)[policy.stateField] !== policy.scheduledValue) {
1488
+ result.skipped.push(candidate.id);
1489
+ continue;
1490
+ }
1491
+ const data = {
1492
+ ...JSON.parse(candidate.data),
1493
+ [policy.stateField]: policy.publishedValue
1494
+ };
1495
+ const context = {
1496
+ input: {
1497
+ typeSlug: type.slug,
1498
+ id: candidate.id,
1499
+ body: { data, publishAt: null }
1500
+ },
1501
+ facts: {
1502
+ contentType: type.slug,
1503
+ recordId: candidate.id,
1504
+ ...candidate.authorId ? { authorId: candidate.authorId } : {}
1505
+ },
1506
+ identity: null
1507
+ };
1508
+ freezeCMSOperationData(context.input);
1509
+ freezeCMSOperationData(context.facts);
1510
+ lifecycle = context;
1511
+ const validation = getContentTypeZodSchema(type).safeParse(data);
1512
+ if (!validation.success)
1513
+ throw new CMSOperationError(
1514
+ 400,
1515
+ "Validation failed",
1516
+ "RECORD_VALIDATION_FAILED",
1517
+ validation.error.issues
1518
+ );
1519
+ await deriveRelationWriteAuthorization(
1520
+ adapter,
1521
+ type,
1522
+ data,
1523
+ ensureSynced
1524
+ );
1525
+ await runBeforeHook(
1526
+ () => hooks?.onBeforeUpdateContent?.(
1527
+ candidate.id,
1528
+ validation.data,
1529
+ updateContext(context)
1530
+ ),
1531
+ "Publication denied"
1532
+ );
1533
+ const item = await adapter.transaction(async (tx) => {
1534
+ const current = await getRecordOrThrow(
1535
+ tx,
1536
+ ensureSynced,
1537
+ type.slug,
1538
+ candidate.id
1539
+ );
1540
+ assertRecordSnapshot(current, {
1541
+ ...candidate,
1542
+ contentType: type
1543
+ });
1544
+ await deriveRelationWriteAuthorization(
1545
+ tx,
1546
+ current.contentType,
1547
+ data,
1548
+ ensureSynced
1549
+ );
1550
+ const currentValidation = getContentTypeZodSchema(
1551
+ current.contentType
1552
+ ).safeParse(data);
1553
+ if (!currentValidation.success)
1554
+ throw new CMSOperationError(
1555
+ 400,
1556
+ "Validation failed",
1557
+ "RECORD_VALIDATION_FAILED",
1558
+ currentValidation.error.issues
1559
+ );
1560
+ const matched = await tx.updateMany({
1561
+ model: "contentItem",
1562
+ where: [
1563
+ { field: "id", value: candidate.id, operator: "eq" },
1564
+ { field: "contentTypeId", value: type.id, operator: "eq" },
1565
+ { field: "data", value: candidate.data, operator: "eq" },
1566
+ {
1567
+ field: "publishAt",
1568
+ value: candidate.publishAt,
1569
+ operator: "eq"
1570
+ },
1571
+ {
1572
+ field: "updatedAt",
1573
+ value: candidate.updatedAt,
1574
+ operator: "eq"
1575
+ }
1576
+ ],
1577
+ update: {
1578
+ data: JSON.stringify(currentValidation.data),
1579
+ publishAt: null,
1580
+ updatedAt: new Date(
1581
+ Math.max(Date.now(), current.updatedAt.getTime() + 1)
1582
+ )
1583
+ }
1584
+ });
1585
+ if (!didUpdateContentItem(matched, candidate.id))
1586
+ throw staleRecordError();
1587
+ const updated = await getRecordOrThrow(
1588
+ tx,
1589
+ ensureSynced,
1590
+ type.slug,
1591
+ candidate.id
1592
+ );
1593
+ await recordContentRevision(tx, updated);
1594
+ return operationContentItem(
1595
+ serializeContentItemWithType(updated)
1596
+ );
1597
+ });
1598
+ committed = true;
1599
+ freezeCMSOperationData(item);
1600
+ result.published.push(candidate.id);
1601
+ await afterUpdateContentItem({ ...context, result: item });
1602
+ const afterPublish = hooks?.onAfterPublishContent;
1603
+ await afterPublish?.(
1604
+ item,
1605
+ Object.freeze({ ...updateContext(context), result: item })
1606
+ );
1607
+ } catch (error) {
1608
+ if (!committed && error instanceof CMSOperationError && (error.code === "RECORD_STATE_CHANGED" || error.code === "RECORD_NOT_FOUND")) {
1609
+ result.skipped.push(candidate.id);
1610
+ } else {
1611
+ result.failures.push({
1612
+ id: candidate.id,
1613
+ message: normalizeOperationError(error, "Publication failed").message,
1614
+ published: committed
1615
+ });
1616
+ if (lifecycle) {
1617
+ try {
1618
+ await notifyError(error, "update", updateContext(lifecycle));
1619
+ } catch {
1620
+ }
1621
+ }
1622
+ }
1623
+ }
1308
1624
  }
1309
- const updated = await getRecordOrThrow(
1310
- tx,
1311
- ensureSynced,
1312
- context.facts.contentType,
1313
- context.facts.recordId
1625
+ return result;
1626
+ };
1627
+ const previous = dispatchQueues.get(adapter) ?? Promise.resolve();
1628
+ const pending = previous.catch(() => {
1629
+ }).then(run);
1630
+ dispatchQueues.set(adapter, pending);
1631
+ try {
1632
+ return await pending;
1633
+ } finally {
1634
+ if (dispatchQueues.get(adapter) === pending)
1635
+ dispatchQueues.delete(adapter);
1636
+ }
1637
+ }
1638
+ });
1639
+ const listContentRevisions = defineOperation({
1640
+ input: CMSContentItemParamsSchema.extend(CMSRevisionsQuerySchema.shape),
1641
+ permission: cmsPermissions.record.read,
1642
+ facts: async ({ input }) => recordFacts(
1643
+ await getRecordOrThrow(adapter, ensureSynced, input.typeSlug, input.id)
1644
+ ),
1645
+ additionalPermissions: ({ facts }) => [
1646
+ cmsPermissions.contentType.read({ contentType: facts.contentType })
1647
+ ],
1648
+ execute: async ({ input, facts }) => {
1649
+ assertRecordFacts(
1650
+ await getRecordOrThrow(adapter, ensureSynced, input.typeSlug, input.id),
1651
+ facts
1652
+ );
1653
+ const where = [
1654
+ { field: "contentItemId", value: input.id, operator: "eq" }
1655
+ ];
1656
+ const revisions = await adapter.findMany({
1657
+ model: "contentRevision",
1658
+ where,
1659
+ sortBy: { field: "version", direction: "desc" },
1660
+ limit: 20,
1661
+ offset: input.offset
1662
+ });
1663
+ return {
1664
+ items: revisions.map((revision) => ({
1665
+ ...revision,
1666
+ createdAt: revision.createdAt.toISOString(),
1667
+ publishAt: revision.publishAt?.toISOString() ?? null
1668
+ })),
1669
+ total: await adapter.count({ model: "contentRevision", where })
1670
+ };
1671
+ },
1672
+ onError: ({ error, ...context }) => notifyError(
1673
+ error,
1674
+ "list",
1675
+ genericContext(context, context.facts.contentType)
1676
+ )
1677
+ });
1678
+ const restoreInputs = /* @__PURE__ */ new WeakMap();
1679
+ const restoreContentRevision = defineOperation({
1680
+ input: CMSRevisionParamsSchema,
1681
+ permission: cmsPermissions.record.update,
1682
+ facts: updateFacts,
1683
+ additionalPermissions: async ({ input, facts }) => {
1684
+ const revision = await adapter.findOne({
1685
+ model: "contentRevision",
1686
+ where: [
1687
+ { field: "id", value: input.revisionId, operator: "eq" },
1688
+ { field: "contentItemId", value: input.id, operator: "eq" }
1689
+ ]
1690
+ });
1691
+ if (!revision)
1692
+ throw new CMSOperationError(
1693
+ 404,
1694
+ "Revision not found",
1695
+ "REVISION_NOT_FOUND"
1314
1696
  );
1315
- return operationContentItem(serializeContentItemWithType(updated));
1697
+ const updateInput = UpdateContentItemInputSchema.parse({
1698
+ typeSlug: input.typeSlug,
1699
+ id: input.id,
1700
+ body: {
1701
+ slug: revision.slug,
1702
+ data: JSON.parse(revision.data),
1703
+ publishAt: revision.publishAt?.toISOString() ?? null
1704
+ }
1316
1705
  });
1706
+ freezeCMSOperationData(updateInput);
1707
+ restoreInputs.set(input, updateInput);
1708
+ return [
1709
+ cmsPermissions.record.read({ ...facts, scope: "record" }),
1710
+ ...await authorizeUpdateRelations({ input: updateInput, facts })
1711
+ ];
1317
1712
  },
1713
+ execute: async (context) => executeUpdateContentItem(
1714
+ { ...context, input: restoreInputs.get(context.input) },
1715
+ true
1716
+ ),
1318
1717
  after: async (context) => {
1319
- const base = updateContext(context);
1320
- const afterUpdateContent = hooks?.onAfterUpdateContent;
1321
- await afterUpdateContent?.(
1322
- context.result,
1323
- Object.freeze({ ...base, result: context.result })
1324
- );
1718
+ const input = restoreInputs.get(context.input);
1719
+ restoreInputs.delete(context.input);
1720
+ await afterUpdateContentItem({ ...context, input });
1325
1721
  },
1326
- onError: async ({ error, ...context }) => {
1327
- await notifyError(error, "update", updateContext(context));
1328
- }
1722
+ onError: ({ error, ...context }) => notifyError(
1723
+ error,
1724
+ "update",
1725
+ genericContext(context, context.facts.contentType)
1726
+ )
1329
1727
  });
1330
1728
  const deleteContentItem = defineOperation({
1331
1729
  input: DeleteContentItemInputSchema,
@@ -1492,7 +1890,8 @@ function createCMSOperations(adapter, options) {
1492
1890
  input: ListContentByRelationInputSchema,
1493
1891
  permission: cmsPermissions.record.read,
1494
1892
  facts: async ({ input }) => collectionFacts(
1495
- (await getContentTypeOrThrow(adapter, ensureSynced, input.typeSlug)).slug
1893
+ (await getContentTypeOrThrow(adapter, ensureSynced, input.typeSlug)).slug,
1894
+ publicationFor(input.typeSlug, input.query.view) ? "content" : void 0
1496
1895
  ),
1497
1896
  additionalPermissions: async ({ input, facts }) => {
1498
1897
  const sourceContentType = await getContentTypeOrThrow(
@@ -1524,7 +1923,8 @@ function createCMSOperations(adapter, options) {
1524
1923
  const result = await getContentItemsByRelationForType(
1525
1924
  adapter,
1526
1925
  sourceContentType,
1527
- input.query
1926
+ input.query,
1927
+ publicationFor(facts.contentType, input.query.view)
1528
1928
  );
1529
1929
  return {
1530
1930
  ...result,
@@ -1661,7 +2061,10 @@ function createCMSOperations(adapter, options) {
1661
2061
  ensureSynced,
1662
2062
  input.sourceType
1663
2063
  );
1664
- return collectionFacts(sourceType.slug);
2064
+ return collectionFacts(
2065
+ sourceType.slug,
2066
+ publicationFor(sourceType.slug, input.query.view) ? "content" : void 0
2067
+ );
1665
2068
  },
1666
2069
  additionalPermissions: async ({ input, facts }) => {
1667
2070
  const authorization = await deriveInverseSourceAuthorization(
@@ -1726,7 +2129,8 @@ function createCMSOperations(adapter, options) {
1726
2129
  targetId: input.query.itemId,
1727
2130
  limit: input.query.limit,
1728
2131
  offset: input.query.offset
1729
- }
2132
+ },
2133
+ publicationFor(facts.contentType, input.query.view)
1730
2134
  );
1731
2135
  return {
1732
2136
  ...result,
@@ -1751,6 +2155,9 @@ function createCMSOperations(adapter, options) {
1751
2155
  getContentItem,
1752
2156
  createContentItem,
1753
2157
  updateContentItem,
2158
+ dispatchDueContent,
2159
+ listContentRevisions,
2160
+ restoreContentRevision,
1754
2161
  deleteContentItem,
1755
2162
  getContentItemPopulated,
1756
2163
  listContentByRelation,
@@ -1759,4 +2166,4 @@ function createCMSOperations(adapter, options) {
1759
2166
  };
1760
2167
  }
1761
2168
 
1762
- export { CMSContentItemParamsSchema, CMSContentTypeParamsSchema, CMSCreateContentItemBodySchema, CMSOperationError, CMSUpdateContentItemBodySchema, createCMSOperations };
2169
+ export { CMSContentItemParamsSchema, CMSContentTypeParamsSchema, CMSCreateContentItemBodySchema, CMSOperationError, CMSRevisionParamsSchema, CMSRevisionsQuerySchema, CMSUpdateContentItemBodySchema, createCMSOperations };