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