@easy-cms/core 0.2.0 → 0.3.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/dist/index.d.ts CHANGED
@@ -332,6 +332,10 @@ interface GlobalHooks {
332
332
  readonly afterChange?: readonly AfterChangeHook[];
333
333
  readonly afterRead?: readonly AfterReadHook[];
334
334
  }
335
+ interface VersionsConfig {
336
+ /** Versions kept per document; older ones are deleted. Default 50. */
337
+ readonly max?: number;
338
+ }
335
339
  interface CollectionConfig {
336
340
  /** URL and table name. Lowercase letters, digits, `-` and `_`. */
337
341
  readonly slug: string;
@@ -344,6 +348,12 @@ interface CollectionConfig {
344
348
  readonly useAsTitle?: string;
345
349
  /** Adds a `status` field (`draft` | `published`). */
346
350
  readonly drafts?: boolean;
351
+ /**
352
+ * Keep a snapshot of every save, to view history and restore. With `drafts`, a draft saved
353
+ * on a published document is kept as a version and the published document stays live until
354
+ * it is published again.
355
+ */
356
+ readonly versions?: boolean | VersionsConfig;
347
357
  readonly access?: CollectionAccess;
348
358
  readonly hooks?: CollectionHooks;
349
359
  }
@@ -352,6 +362,8 @@ interface GlobalConfig {
352
362
  readonly label?: Label;
353
363
  readonly fields: readonly Field[];
354
364
  readonly drafts?: boolean;
365
+ /** Keep a snapshot of every save. See `CollectionConfig.versions`. */
366
+ readonly versions?: boolean | VersionsConfig;
355
367
  readonly access?: GlobalAccess;
356
368
  readonly hooks?: GlobalHooks;
357
369
  }
@@ -647,6 +659,23 @@ type GlobalInput<C extends Config, S extends GlobalSlug<C>> = Partial<Simplify<F
647
659
  readonly slug: S;
648
660
  }>>>>;
649
661
 
662
+ declare const DEFAULT_MAX_VERSIONS = 50;
663
+ /** One saved state of a document or global. */
664
+ interface VersionSummary {
665
+ readonly id: ID;
666
+ /** `draft` or `published` with drafts enabled, otherwise `null`. */
667
+ readonly status: 'draft' | 'published' | null;
668
+ /** The newest version: the document's current editable state. */
669
+ readonly latest: boolean;
670
+ /** Who saved it (user id), or `null` for saves without a user (scripts, seeds). */
671
+ readonly author: ID | null;
672
+ readonly createdAt: string;
673
+ }
674
+ interface Version<T = Record<string, unknown>> extends VersionSummary {
675
+ /** The document as it was saved (hidden fields such as password hashes are never kept). */
676
+ readonly data: T;
677
+ }
678
+
650
679
  type Data$1 = Record<string, unknown>;
651
680
  type Slug<C extends Config> = string extends CollectionSlug<C> ? string : CollectionSlug<C>;
652
681
  type GSlug<C extends Config> = string extends GlobalSlug<C> ? string : GlobalSlug<C>;
@@ -702,6 +731,7 @@ declare class EasyCMS<C extends Config = Config> {
702
731
  readonly auth: Auth;
703
732
  /** Where uploaded files are stored. */
704
733
  readonly storage: StorageAdapter;
734
+ private readonly versions;
705
735
  constructor(config: ResolvedConfig, db: Database, logger?: Logger, storage?: StorageAdapter);
706
736
  find<S extends Slug<C>>(collection: S, options?: FindOptions): Promise<PaginatedDocs<Doc<C, S>>>;
707
737
  findById<S extends Slug<C>>(collection: S, id: ID, options?: ReadOptions): Promise<Doc<C, S> | null>;
@@ -721,9 +751,47 @@ declare class EasyCMS<C extends Config = Config> {
721
751
  /** Public URL of a stored file. */
722
752
  mediaURL(key: string): string;
723
753
  update<S extends Slug<C>>(collection: S, id: ID, data: Update<C, S>, options?: DepthOptions): Promise<Doc<C, S>>;
754
+ /**
755
+ * Takes a document off the site: its status becomes `draft`. With versions and drafts, a plain
756
+ * `update` with `status: 'draft'` only saves a draft and leaves the published document live;
757
+ * this is how to unpublish it.
758
+ */
759
+ unpublish<S extends Slug<C>>(collection: S, id: ID, options?: DepthOptions): Promise<Doc<C, S>>;
760
+ /** Throws away the unpublished draft of a published document, going back to what is live. */
761
+ discardDraft<S extends Slug<C>>(collection: S, id: ID, options?: DepthOptions): Promise<Doc<C, S>>;
762
+ /** Saved versions of a document, newest first. Needs update access to the document. */
763
+ findVersions<S extends Slug<C>>(collection: S, id: ID, options?: AccessOptions & {
764
+ limit?: number;
765
+ page?: number;
766
+ }): Promise<PaginatedDocs<VersionSummary>>;
767
+ /** One saved version with the document as it was then. */
768
+ findVersion<S extends Slug<C>>(collection: S, id: ID, versionId: ID, options?: DepthOptions): Promise<Version<Doc<C, S>> | null>;
769
+ /**
770
+ * Makes an old version the current content, as a new version. With drafts it is restored as
771
+ * a draft (publish it to put it live); access, hooks and validation run as for `update`.
772
+ */
773
+ restoreVersion<S extends Slug<C>>(collection: S, id: ID, versionId: ID, options?: DepthOptions): Promise<Doc<C, S>>;
774
+ private versionTarget;
775
+ private updateDocument;
724
776
  delete<S extends Slug<C>>(collection: S, id: ID, options?: AccessOptions): Promise<Doc<C, S>>;
725
777
  findGlobal<S extends GSlug<C>>(slug: S, options?: ReadOptions): Promise<GDoc<C, S>>;
726
778
  updateGlobal<S extends GSlug<C>>(slug: S, data: GInput<C, S>, options?: DepthOptions): Promise<GDoc<C, S>>;
779
+ /** Takes a global off the site (`status: 'draft'`). See `unpublish`. */
780
+ unpublishGlobal<S extends GSlug<C>>(slug: S, options?: DepthOptions): Promise<GDoc<C, S>>;
781
+ /** Throws away the unpublished draft of a published global. */
782
+ discardGlobalDraft<S extends GSlug<C>>(slug: S, options?: DepthOptions): Promise<GDoc<C, S>>;
783
+ /** Saved versions of a global, newest first. Needs update access. */
784
+ findGlobalVersions<S extends GSlug<C>>(slug: S, options?: AccessOptions & {
785
+ limit?: number;
786
+ page?: number;
787
+ }): Promise<PaginatedDocs<VersionSummary>>;
788
+ findGlobalVersion<S extends GSlug<C>>(slug: S, versionId: ID, options?: DepthOptions): Promise<Version<GDoc<C, S>> | null>;
789
+ /** Makes an old version of a global current (as a draft when the global has drafts). */
790
+ restoreGlobalVersion<S extends GSlug<C>>(slug: S, versionId: ID, options?: DepthOptions): Promise<GDoc<C, S>>;
791
+ private globalVersionTarget;
792
+ /** The global's pending draft when it has one (see `withDrafts`), otherwise `saved`. */
793
+ private globalDraft;
794
+ private saveGlobal;
727
795
  /**
728
796
  * What `user` may do with one document. Resolves `where`-style access
729
797
  * against the document, so the admin UI can hide actions precisely.
@@ -746,6 +814,12 @@ declare class EasyCMS<C extends Config = Config> {
746
814
  private output;
747
815
  /** Creates a document: access, input filtering, hooks, validation, then afterChange. */
748
816
  private createDocument;
817
+ /** Drafts are kept as versions (instead of unpublishing) when both are enabled. */
818
+ private separateDrafts;
819
+ /** Replaces published documents that have a newer draft with that draft. */
820
+ private withDrafts;
821
+ /** Records a version after a save, when the collection or global keeps versions. */
822
+ private saveVersion;
749
823
  private hookArgs;
750
824
  /**
751
825
  * Runs hooks that may replace a value, passed as `data` (before hooks) or `doc` (afterRead).
@@ -1001,6 +1075,7 @@ interface AdminCollection {
1001
1075
  };
1002
1076
  useAsTitle?: string;
1003
1077
  drafts: boolean;
1078
+ versions: boolean;
1004
1079
  fields: AdminField[];
1005
1080
  permissions: {
1006
1081
  read: boolean;
@@ -1013,6 +1088,7 @@ interface AdminGlobal {
1013
1088
  slug: string;
1014
1089
  label?: Label;
1015
1090
  drafts: boolean;
1091
+ versions: boolean;
1016
1092
  fields: AdminField[];
1017
1093
  permissions: {
1018
1094
  read: boolean;
@@ -1053,4 +1129,4 @@ declare const BUILTIN_COLLECTIONS: readonly ["users", "media"];
1053
1129
  /** Checks a config and returns every problem found. An empty array means it is valid. */
1054
1130
  declare function validateConfig(config: Config): ConfigIssue[];
1055
1131
 
1056
- export { type Access, type AccessArgs, type AccessOptions, type AdminCollection, type AdminConfig, type AdminField, type AdminGlobal, type AdminLocale, type AdminSchema, type AfterChangeHook, type AfterDeleteHook, type AfterReadHook, type ArrayField, Auth, type AuthConfig, type AuthUser, BUILTIN_COLLECTIONS, type BeforeChangeHook, type BeforeDeleteHook, type BeforeValidateHook, type BooleanField, type BuiltinMediaCollection, type BuiltinUsersCollection, type BundleHints, CONFIG_FILE_NAMES, CSRF_COOKIE, CSRF_HEADER, type CollectionAccess, type CollectionConfig, type CollectionDocument, type CollectionHooks, type CollectionSlug, type Config, ConfigError, type ConfigIssue, type CreateEasyCMSOptions, type CreateInput, type CreateMigrationResult, DEFAULT_ADMIN_PATH, DEFAULT_API_PATH, DEFAULT_DEPTH, DEFAULT_MAX_FILE_SIZE, DEFAULT_TOKEN_EXPIRATION, type Database, type DatabaseAdapter, type DatabaseInitArgs, type DateField, type DepthOptions, EXTENSIONS, EasyCMS, EasyCMSError, type EmailField, FIELD_TYPES, type Field, type FieldAccess, type FieldAccessFn, type FieldError, type FieldType, type FieldValidate, type FieldValidateContext, type FieldValue, type FieldsInput, type FieldsValue, type FindArgs, type FindOptions, ForbiddenError, type GlobalAccess, type GlobalConfig, type GlobalDocument, type GlobalHooks, type GlobalInput, type GlobalSlug, type GroupField, type ID, INTERNAL_COLLECTIONS, type ImageSize, type InferCollection, type InferGlobal, type JsonField, type Label, type LoadConfigOptions, type LocalStorageOptions, type Logger, type LoginArgs, MAX_DEPTH, MEDIA, MIN_PASSWORD_LENGTH, MIN_SECRET_LENGTH, type MediaDocument, type MediaSize, type MigrationInfo, NotFoundError, type NumberField, type Operation, type PaginatedDocs, PayloadTooLargeError, type Plugin, QueryError, type RawDocument, type ReadOptions, type RelationshipField, type ResolvedConfig, type RestHandler, type RestHandlerOptions, type RichTextDocument, type RichTextField, type Role, type RoutesConfig, SESSION_COOKIE, SchemaError, type SchemaMode, type SelectField, type SelectOption, type Session, type SlugField, type StorageAdapter, type StoredFile, type TextField, type TextareaField, TooManyRequestsError, UnauthorizedError, type UpdateInput, type UploadConfig, type UploadField, ValidationError, type Where, type WhereOperators, anyone, applyDefaults, consoleLogger, createEasyCMS, createRestHandler, defineConfig, fillMissing, findConfigFile, generateSlugs, generateTypes, hashPassword, imageDimensions, importConfig, isAdmin, isLoggedIn, loadConfig, localStorage, mergeForUpdate, mimeAllowed, parseId, populate, readCookie, resolveConfig, silentLogger, singularize, slugify, sniffMimeType, validateConfig, validateFields, verifyPassword };
1132
+ export { type Access, type AccessArgs, type AccessOptions, type AdminCollection, type AdminConfig, type AdminField, type AdminGlobal, type AdminLocale, type AdminSchema, type AfterChangeHook, type AfterDeleteHook, type AfterReadHook, type ArrayField, Auth, type AuthConfig, type AuthUser, BUILTIN_COLLECTIONS, type BeforeChangeHook, type BeforeDeleteHook, type BeforeValidateHook, type BooleanField, type BuiltinMediaCollection, type BuiltinUsersCollection, type BundleHints, CONFIG_FILE_NAMES, CSRF_COOKIE, CSRF_HEADER, type CollectionAccess, type CollectionConfig, type CollectionDocument, type CollectionHooks, type CollectionSlug, type Config, ConfigError, type ConfigIssue, type CreateEasyCMSOptions, type CreateInput, type CreateMigrationResult, DEFAULT_ADMIN_PATH, DEFAULT_API_PATH, DEFAULT_DEPTH, DEFAULT_MAX_FILE_SIZE, DEFAULT_MAX_VERSIONS, DEFAULT_TOKEN_EXPIRATION, type Database, type DatabaseAdapter, type DatabaseInitArgs, type DateField, type DepthOptions, EXTENSIONS, EasyCMS, EasyCMSError, type EmailField, FIELD_TYPES, type Field, type FieldAccess, type FieldAccessFn, type FieldError, type FieldType, type FieldValidate, type FieldValidateContext, type FieldValue, type FieldsInput, type FieldsValue, type FindArgs, type FindOptions, ForbiddenError, type GlobalAccess, type GlobalConfig, type GlobalDocument, type GlobalHooks, type GlobalInput, type GlobalSlug, type GroupField, type ID, INTERNAL_COLLECTIONS, type ImageSize, type InferCollection, type InferGlobal, type JsonField, type Label, type LoadConfigOptions, type LocalStorageOptions, type Logger, type LoginArgs, MAX_DEPTH, MEDIA, MIN_PASSWORD_LENGTH, MIN_SECRET_LENGTH, type MediaDocument, type MediaSize, type MigrationInfo, NotFoundError, type NumberField, type Operation, type PaginatedDocs, PayloadTooLargeError, type Plugin, QueryError, type RawDocument, type ReadOptions, type RelationshipField, type ResolvedConfig, type RestHandler, type RestHandlerOptions, type RichTextDocument, type RichTextField, type Role, type RoutesConfig, SESSION_COOKIE, SchemaError, type SchemaMode, type SelectField, type SelectOption, type Session, type SlugField, type StorageAdapter, type StoredFile, type TextField, type TextareaField, TooManyRequestsError, UnauthorizedError, type UpdateInput, type UploadConfig, type UploadField, ValidationError, type Version, type VersionSummary, type VersionsConfig, type Where, type WhereOperators, anyone, applyDefaults, consoleLogger, createEasyCMS, createRestHandler, defineConfig, fillMissing, findConfigFile, generateSlugs, generateTypes, hashPassword, imageDimensions, importConfig, isAdmin, isLoggedIn, loadConfig, localStorage, mergeForUpdate, mimeAllowed, parseId, populate, readCookie, resolveConfig, silentLogger, singularize, slugify, sniffMimeType, validateConfig, validateFields, verifyPassword };
package/dist/index.js CHANGED
@@ -160,7 +160,12 @@ var USERS = "users";
160
160
  var MEDIA = "media";
161
161
  var SESSIONS = "sessions";
162
162
  var LOGIN_ATTEMPTS = "login-attempts";
163
- var INTERNAL_COLLECTIONS = /* @__PURE__ */ new Set([SESSIONS, LOGIN_ATTEMPTS]);
163
+ var VERSIONS = "document-versions";
164
+ var INTERNAL_COLLECTIONS = /* @__PURE__ */ new Set([
165
+ SESSIONS,
166
+ LOGIN_ATTEMPTS,
167
+ VERSIONS
168
+ ]);
164
169
  var DEFAULT_ROLES = ["admin", "editor"];
165
170
  var adminOrSelf = ({ user }) => {
166
171
  if (!user) return false;
@@ -325,6 +330,21 @@ var internalCollections = [
325
330
  fields: [{ name: "key", type: "text", required: true, index: true }]
326
331
  }
327
332
  ];
333
+ var versionsCollection = {
334
+ slug: VERSIONS,
335
+ access: { read: nobody, create: nobody, update: nobody, delete: nobody },
336
+ fields: [
337
+ // Collection slug, or `global:<slug>`.
338
+ { name: "parent", type: "text", required: true, index: true },
339
+ // Document id; 0 for globals.
340
+ { name: "doc", type: "number", required: true, index: true },
341
+ { name: "status", type: "text" },
342
+ // The newest version of the document: its current editable state.
343
+ { name: "latest", type: "boolean", index: true },
344
+ { name: "author", type: "number" },
345
+ { name: "snapshot", type: "json", required: true }
346
+ ]
347
+ };
328
348
 
329
349
  // src/auth/password.ts
330
350
  import { randomBytes, scrypt, timingSafeEqual } from "crypto";
@@ -847,6 +867,7 @@ var RESERVED_SLUGS = /* @__PURE__ */ new Set([
847
867
  "globals",
848
868
  "sessions",
849
869
  "login-attempts",
870
+ "document-versions",
850
871
  "migrations",
851
872
  "access"
852
873
  ]);
@@ -1002,6 +1023,15 @@ function validateContainer(container, path, collectionSlugs, add) {
1002
1023
  add(`${path}.fields`, "must be an array");
1003
1024
  return;
1004
1025
  }
1026
+ const versions = container.versions;
1027
+ if (versions !== void 0 && typeof versions !== "boolean") {
1028
+ const max = typeof versions === "object" && versions !== null ? versions.max : void 0;
1029
+ if (typeof versions !== "object" || versions === null) {
1030
+ add(`${path}.versions`, "must be true, false or { max }");
1031
+ } else if (max !== void 0 && (!Number.isInteger(max) || max < 1)) {
1032
+ add(`${path}.versions.max`, "must be a positive integer");
1033
+ }
1034
+ }
1005
1035
  const reserved = new Set(SYSTEM_FIELD_NAMES);
1006
1036
  if (container.drafts) reserved.add("status");
1007
1037
  validateFields2(container.fields, `${path}.fields`, reserved, collectionSlugs, add);
@@ -1161,12 +1191,19 @@ async function resolveConfig(input) {
1161
1191
  lockWindow: config.auth?.lockWindow ?? 15 * 60,
1162
1192
  trustedOrigins: config.auth?.trustedOrigins ?? []
1163
1193
  },
1164
- collections: [...config.collections ?? [], ...internalCollections],
1194
+ collections: [
1195
+ ...config.collections ?? [],
1196
+ ...internalCollections,
1197
+ ...usesVersions(config) ? [versionsCollection] : []
1198
+ ],
1165
1199
  globals: config.globals ?? []
1166
1200
  };
1167
1201
  resolved.add(result);
1168
1202
  return result;
1169
1203
  }
1204
+ function usesVersions(config) {
1205
+ return [...config.collections ?? [], ...config.globals ?? []].some((c) => c.versions);
1206
+ }
1170
1207
 
1171
1208
  // src/load-config.ts
1172
1209
  var CONFIG_FILE_NAMES = [
@@ -1429,6 +1466,137 @@ function localStorage(options = {}) {
1429
1466
  };
1430
1467
  }
1431
1468
 
1469
+ // src/versions.ts
1470
+ var DEFAULT_MAX_VERSIONS = 50;
1471
+ function versionLimit(config) {
1472
+ const versions = config.versions;
1473
+ if (!versions) return void 0;
1474
+ return versions === true ? DEFAULT_MAX_VERSIONS : versions.max ?? DEFAULT_MAX_VERSIONS;
1475
+ }
1476
+ var collectionParent = (slug) => slug;
1477
+ var globalParent = (slug) => `global:${slug}`;
1478
+ function snapshotOf(fields, doc) {
1479
+ const hidden = new Set(fields.filter((f) => f.hidden).map((f) => f.name));
1480
+ const snapshot = {};
1481
+ for (const [key2, value] of Object.entries(doc)) {
1482
+ if (key2 === "id" || key2 === "createdAt" || key2 === "updatedAt" || hidden.has(key2)) continue;
1483
+ snapshot[key2] = value;
1484
+ }
1485
+ return snapshot;
1486
+ }
1487
+ var toSummary = (row) => ({
1488
+ id: row.id,
1489
+ status: row.status ?? null,
1490
+ latest: row.latest === true,
1491
+ author: row.author ?? null,
1492
+ createdAt: String(row.createdAt)
1493
+ });
1494
+ var VersionStore = class {
1495
+ constructor(db) {
1496
+ this.db = db;
1497
+ }
1498
+ db;
1499
+ /** Saves a new latest version and deletes the oldest beyond `max`. */
1500
+ async save(args) {
1501
+ const { parent, doc } = args;
1502
+ const previous = await this.db.find({
1503
+ collection: VERSIONS,
1504
+ where: { and: [...this.of(parent, doc), { latest: { equals: true } }] },
1505
+ sort: [],
1506
+ limit: 0,
1507
+ page: 1
1508
+ });
1509
+ for (const row of previous.docs) {
1510
+ await this.db.update({ collection: VERSIONS, id: row.id, data: { ...row, latest: false } });
1511
+ }
1512
+ const now = (/* @__PURE__ */ new Date()).toISOString();
1513
+ const created = await this.db.create({
1514
+ collection: VERSIONS,
1515
+ data: {
1516
+ parent,
1517
+ doc,
1518
+ status: args.status,
1519
+ latest: true,
1520
+ author: args.author,
1521
+ snapshot: args.snapshot,
1522
+ createdAt: now,
1523
+ updatedAt: now
1524
+ }
1525
+ });
1526
+ await this.prune(parent, doc, args.max);
1527
+ return toSummary(created);
1528
+ }
1529
+ /** The latest version of each document, keyed by document id. */
1530
+ async latest(parent, docs) {
1531
+ const result = /* @__PURE__ */ new Map();
1532
+ if (docs.length === 0) return result;
1533
+ const rows = await this.db.find({
1534
+ collection: VERSIONS,
1535
+ where: {
1536
+ and: [
1537
+ { parent: { equals: parent } },
1538
+ { doc: { in: [...docs] } },
1539
+ { latest: { equals: true } }
1540
+ ]
1541
+ },
1542
+ sort: [],
1543
+ limit: 0,
1544
+ page: 1
1545
+ });
1546
+ for (const row of rows.docs) result.set(String(row.doc), this.toVersion(row));
1547
+ return result;
1548
+ }
1549
+ async list(parent, doc, limit, page) {
1550
+ const found = await this.db.find({
1551
+ collection: VERSIONS,
1552
+ where: { and: this.of(parent, doc) },
1553
+ sort: ["-id"],
1554
+ limit,
1555
+ page
1556
+ });
1557
+ return { ...found, docs: found.docs.map(toSummary) };
1558
+ }
1559
+ async get(parent, doc, id) {
1560
+ const found = await this.db.find({
1561
+ collection: VERSIONS,
1562
+ where: { and: [...this.of(parent, doc), { id: { equals: id } }] },
1563
+ sort: [],
1564
+ limit: 1,
1565
+ page: 1
1566
+ });
1567
+ const row = found.docs[0];
1568
+ return row ? this.toVersion(row) : null;
1569
+ }
1570
+ async deleteAll(parent, doc) {
1571
+ const rows = await this.db.find({
1572
+ collection: VERSIONS,
1573
+ where: { and: this.of(parent, doc) },
1574
+ sort: [],
1575
+ limit: 0,
1576
+ page: 1
1577
+ });
1578
+ for (const row of rows.docs) await this.db.delete({ collection: VERSIONS, id: row.id });
1579
+ }
1580
+ async prune(parent, doc, max) {
1581
+ const rows = await this.db.find({
1582
+ collection: VERSIONS,
1583
+ where: { and: this.of(parent, doc) },
1584
+ sort: ["-id"],
1585
+ limit: 0,
1586
+ page: 1
1587
+ });
1588
+ for (const row of rows.docs.slice(max)) {
1589
+ await this.db.delete({ collection: VERSIONS, id: row.id });
1590
+ }
1591
+ }
1592
+ of(parent, doc) {
1593
+ return [{ parent: { equals: parent } }, { doc: { equals: doc } }];
1594
+ }
1595
+ toVersion(row) {
1596
+ return { ...toSummary(row), data: row.snapshot ?? {} };
1597
+ }
1598
+ };
1599
+
1432
1600
  // src/local-api.ts
1433
1601
  async function createEasyCMS(config, options = {}) {
1434
1602
  const resolved2 = await resolveConfig(config);
@@ -1453,11 +1621,13 @@ var EasyCMS = class {
1453
1621
  auth;
1454
1622
  /** Where uploaded files are stored. */
1455
1623
  storage;
1624
+ versions;
1456
1625
  constructor(config, db, logger = consoleLogger, storage = localStorage({ dir: config.upload.dir })) {
1457
1626
  this.config = config;
1458
1627
  this.db = db;
1459
1628
  this.logger = logger;
1460
1629
  this.storage = storage;
1630
+ this.versions = new VersionStore(db);
1461
1631
  this.auth = new Auth(this);
1462
1632
  }
1463
1633
  async find(collection2, options = {}) {
@@ -1475,7 +1645,8 @@ var EasyCMS = class {
1475
1645
  );
1476
1646
  const sort = options.sort === void 0 ? ["-createdAt"] : [options.sort].flat();
1477
1647
  const result = await this.db.find({ collection: collection2, where, sort, limit, page });
1478
- const docs = await this.output(config, result.docs, guard, options);
1648
+ const found = options.draft ? await this.withDrafts(config, result.docs) : result.docs;
1649
+ const docs = await this.output(config, found, guard, options);
1479
1650
  return { ...result, docs };
1480
1651
  }
1481
1652
  async findById(collection2, id, options = {}) {
@@ -1492,7 +1663,8 @@ var EasyCMS = class {
1492
1663
  page: 1
1493
1664
  })).docs[0] : await this.db.findById({ collection: collection2, id: parsed });
1494
1665
  if (!doc) return null;
1495
- const [out] = await this.output(config, [doc], guard, options);
1666
+ const [current] = options.draft ? await this.withDrafts(config, [doc]) : [doc];
1667
+ const [out] = await this.output(config, [current], guard, options);
1496
1668
  return out ?? null;
1497
1669
  }
1498
1670
  async count(collection2, options = {}) {
@@ -1577,27 +1749,113 @@ var EasyCMS = class {
1577
1749
  return `${this.config.serverURL?.replace(/\/+$/, "") ?? ""}${this.config.routes.api}/media/file/${encodeURIComponent(key2)}`;
1578
1750
  }
1579
1751
  async update(collection2, id, data, options = {}) {
1752
+ return await this.updateDocument(collection2, id, asObject(data, collection2), options);
1753
+ }
1754
+ /**
1755
+ * Takes a document off the site: its status becomes `draft`. With versions and drafts, a plain
1756
+ * `update` with `status: 'draft'` only saves a draft and leaves the published document live;
1757
+ * this is how to unpublish it.
1758
+ */
1759
+ async unpublish(collection2, id, options = {}) {
1760
+ const config = this.collection(collection2);
1761
+ if (!config.drafts) throw new QueryError(`"${collection2}" has no drafts`);
1762
+ return await this.updateDocument(
1763
+ collection2,
1764
+ id,
1765
+ { status: "draft" },
1766
+ options,
1767
+ "unpublish"
1768
+ );
1769
+ }
1770
+ /** Throws away the unpublished draft of a published document, going back to what is live. */
1771
+ async discardDraft(collection2, id, options = {}) {
1772
+ const config = this.collection(collection2);
1773
+ const guard = guardOf(options);
1774
+ const parsed = parseId(id);
1775
+ const existing = parsed === void 0 ? null : await this.db.findById({ collection: collection2, id: parsed });
1776
+ if (!existing || parsed === void 0) throw new NotFoundError(collection2, id);
1777
+ await this.checkDocumentAccess(config, "update", guard, parsed, void 0);
1778
+ const [current] = await this.withDrafts(config, [existing]);
1779
+ if (current !== existing) {
1780
+ await this.saveVersion(config, collectionParent(collection2), parsed, existing, guard);
1781
+ }
1782
+ const [out] = await this.output(config, [existing], guard, { ...options, draft: true });
1783
+ return out;
1784
+ }
1785
+ /** Saved versions of a document, newest first. Needs update access to the document. */
1786
+ async findVersions(collection2, id, options = {}) {
1787
+ const { config, parsed } = await this.versionTarget(collection2, id, options);
1788
+ return this.versions.list(
1789
+ collectionParent(config.slug),
1790
+ parsed,
1791
+ options.limit ?? 20,
1792
+ options.page ?? 1
1793
+ );
1794
+ }
1795
+ /** One saved version with the document as it was then. */
1796
+ async findVersion(collection2, id, versionId, options = {}) {
1797
+ const { config, parsed, existing } = await this.versionTarget(collection2, id, options);
1798
+ const version = await this.versions.get(collectionParent(config.slug), parsed, versionId);
1799
+ if (!version) return null;
1800
+ const doc = {
1801
+ ...version.data,
1802
+ id: parsed,
1803
+ createdAt: existing.createdAt,
1804
+ updatedAt: version.createdAt
1805
+ };
1806
+ const [out] = await this.output(config, [doc], guardOf(options), {
1807
+ ...options,
1808
+ draft: true
1809
+ });
1810
+ return { ...version, data: out };
1811
+ }
1812
+ /**
1813
+ * Makes an old version the current content, as a new version. With drafts it is restored as
1814
+ * a draft (publish it to put it live); access, hooks and validation run as for `update`.
1815
+ */
1816
+ async restoreVersion(collection2, id, versionId, options = {}) {
1817
+ const { config, parsed } = await this.versionTarget(collection2, id, options);
1818
+ const version = await this.versions.get(collectionParent(config.slug), parsed, versionId);
1819
+ if (!version) throw new NotFoundError(`${collection2} version`, versionId);
1820
+ const { status: _status, ...data } = version.data;
1821
+ return await this.updateDocument(
1822
+ collection2,
1823
+ parsed,
1824
+ config.drafts ? { ...data, status: "draft" } : data,
1825
+ options
1826
+ );
1827
+ }
1828
+ async versionTarget(collection2, id, options) {
1829
+ const config = this.collection(collection2);
1830
+ if (!versionLimit(config)) throw new QueryError(`"${collection2}" has no versions`);
1831
+ const parsed = parseId(id);
1832
+ const existing = parsed === void 0 ? null : await this.db.findById({ collection: collection2, id: parsed });
1833
+ if (!existing || parsed === void 0) throw new NotFoundError(collection2, id);
1834
+ await this.checkDocumentAccess(config, "update", guardOf(options), parsed, void 0);
1835
+ return { config, parsed, existing };
1836
+ }
1837
+ async updateDocument(collection2, id, raw, options, mode = "save") {
1580
1838
  const config = this.collection(collection2);
1581
1839
  const guard = guardOf(options);
1582
- const raw = asObject(data, collection2);
1583
1840
  const parsed = parseId(id);
1584
1841
  const existing = parsed === void 0 ? null : await this.db.findById({ collection: collection2, id: parsed });
1585
1842
  if (!existing || parsed === void 0) throw new NotFoundError(collection2, id);
1586
1843
  await this.checkDocumentAccess(config, "update", guard, parsed, raw);
1844
+ const [current] = await this.withDrafts(config, [existing]);
1587
1845
  const { input, password } = splitPassword(config, raw);
1588
1846
  const filtered = await filterInput(
1589
1847
  config.fields,
1590
1848
  input,
1591
1849
  this.fieldChecker("update", guard, parsed, input)
1592
1850
  );
1593
- let merged = mergeForUpdate(config.fields, existing, filtered);
1851
+ let merged = mergeForUpdate(config.fields, current, filtered);
1594
1852
  if (config.drafts)
1595
- merged.status = Object.hasOwn(filtered, "status") ? filtered.status : existing.status;
1853
+ merged.status = Object.hasOwn(filtered, "status") ? filtered.status : current.status;
1596
1854
  const base = this.hookArgs(config, guard);
1597
1855
  merged = await this.transform(
1598
1856
  config.hooks?.beforeValidate,
1599
1857
  "data",
1600
- { ...base, operation: "update", originalDoc: existing },
1858
+ { ...base, operation: "update", originalDoc: current },
1601
1859
  merged
1602
1860
  );
1603
1861
  let prepared = await this.prepare(
@@ -1609,21 +1867,43 @@ var EasyCMS = class {
1609
1867
  prepared = await this.transform(
1610
1868
  config.hooks?.beforeChange,
1611
1869
  "data",
1612
- { ...base, operation: "update", originalDoc: existing },
1870
+ { ...base, operation: "update", originalDoc: current },
1613
1871
  prepared
1614
1872
  );
1873
+ const now = (/* @__PURE__ */ new Date()).toISOString();
1874
+ if (mode === "save" && this.separateDrafts(config) && existing.status === "published" && prepared.status === "draft") {
1875
+ if (password !== void 0) {
1876
+ throw new ValidationError(collection2, [
1877
+ { field: "password", message: "cannot be changed in a draft; publish instead" }
1878
+ ]);
1879
+ }
1880
+ const draft = { ...prepared, id: parsed, createdAt: existing.createdAt, updatedAt: now };
1881
+ await this.saveVersion(config, collectionParent(collection2), parsed, draft, guard);
1882
+ await this.notify(config.hooks?.afterChange, "afterChange", config.slug, {
1883
+ ...base,
1884
+ doc: draft,
1885
+ previousDoc: current,
1886
+ operation: "update"
1887
+ });
1888
+ const [out2] = await this.output(config, [draft], guard, {
1889
+ ...options,
1890
+ draft: true
1891
+ });
1892
+ return out2;
1893
+ }
1615
1894
  if (config.slug === USERS) await this.guardLastAdmin(parsed, existing, prepared);
1616
1895
  if (password !== void 0) prepared.passwordHash = await hashPassword(password);
1617
1896
  const doc = await this.db.update({
1618
1897
  collection: collection2,
1619
1898
  id: parsed,
1620
- data: { ...prepared, createdAt: existing.createdAt, updatedAt: (/* @__PURE__ */ new Date()).toISOString() }
1899
+ data: { ...prepared, createdAt: existing.createdAt, updatedAt: now }
1621
1900
  });
1901
+ await this.saveVersion(config, collectionParent(collection2), parsed, doc, guard);
1622
1902
  if (password !== void 0) await this.auth.revokeSessions(parsed);
1623
1903
  await this.notify(config.hooks?.afterChange, "afterChange", config.slug, {
1624
1904
  ...base,
1625
1905
  doc,
1626
- previousDoc: existing,
1906
+ previousDoc: current,
1627
1907
  operation: "update"
1628
1908
  });
1629
1909
  const [out] = await this.output(config, [doc], guard, { ...options, draft: true });
@@ -1642,6 +1922,7 @@ var EasyCMS = class {
1642
1922
  for (const hook of config.hooks?.beforeDelete ?? []) await hook({ ...base, id: parsed });
1643
1923
  if (config.slug === USERS) await this.auth.revokeSessions(parsed);
1644
1924
  await this.db.delete({ collection: collection2, id: parsed });
1925
+ if (versionLimit(config)) await this.versions.deleteAll(collectionParent(collection2), parsed);
1645
1926
  await this.notify(config.hooks?.afterDelete, "afterDelete", config.slug, {
1646
1927
  ...base,
1647
1928
  id: parsed,
@@ -1654,7 +1935,8 @@ var EasyCMS = class {
1654
1935
  const config = this.global(slug);
1655
1936
  const guard = guardOf(options);
1656
1937
  await this.checkGlobalAccess(config, "read", guard);
1657
- const stored = await this.db.findGlobal({ slug });
1938
+ const saved = await this.db.findGlobal({ slug });
1939
+ const stored = saved && options.draft ? await this.globalDraft(config, saved) : saved;
1658
1940
  const data = fillMissing(config.fields, applyDefaults(config.fields, stored ?? {}));
1659
1941
  if (!stored) {
1660
1942
  data.updatedAt = null;
@@ -1665,37 +1947,108 @@ var EasyCMS = class {
1665
1947
  return doc;
1666
1948
  }
1667
1949
  async updateGlobal(slug, data, options = {}) {
1950
+ return this.saveGlobal(slug, asObject(data, slug), options);
1951
+ }
1952
+ /** Takes a global off the site (`status: 'draft'`). See `unpublish`. */
1953
+ async unpublishGlobal(slug, options = {}) {
1954
+ if (!this.global(slug).drafts) throw new QueryError(`"${slug}" has no drafts`);
1955
+ return this.saveGlobal(slug, { status: "draft" }, options, "unpublish");
1956
+ }
1957
+ /** Throws away the unpublished draft of a published global. */
1958
+ async discardGlobalDraft(slug, options = {}) {
1959
+ const config = this.global(slug);
1960
+ const guard = guardOf(options);
1961
+ await this.checkGlobalAccess(config, "update", guard);
1962
+ const saved = await this.db.findGlobal({ slug });
1963
+ if (saved && await this.globalDraft(config, saved) !== saved) {
1964
+ await this.saveVersion(config, globalParent(slug), 0, saved, guard);
1965
+ }
1966
+ return this.findGlobal(slug, { ...options, draft: true });
1967
+ }
1968
+ /** Saved versions of a global, newest first. Needs update access. */
1969
+ async findGlobalVersions(slug, options = {}) {
1970
+ await this.globalVersionTarget(slug, options);
1971
+ return this.versions.list(globalParent(slug), 0, options.limit ?? 20, options.page ?? 1);
1972
+ }
1973
+ async findGlobalVersion(slug, versionId, options = {}) {
1974
+ const config = await this.globalVersionTarget(slug, options);
1975
+ const version = await this.versions.get(globalParent(slug), 0, versionId);
1976
+ if (!version) return null;
1977
+ const data = fillMissing(config.fields, applyDefaults(config.fields, version.data));
1978
+ const [out] = await this.output(config, [{ ...data, id: 0 }], guardOf(options), {
1979
+ ...options,
1980
+ draft: true
1981
+ });
1982
+ const { id: _id, ...doc } = out;
1983
+ return { ...version, data: { ...doc, updatedAt: version.createdAt } };
1984
+ }
1985
+ /** Makes an old version of a global current (as a draft when the global has drafts). */
1986
+ async restoreGlobalVersion(slug, versionId, options = {}) {
1987
+ const config = await this.globalVersionTarget(slug, options);
1988
+ const version = await this.versions.get(globalParent(slug), 0, versionId);
1989
+ if (!version) throw new NotFoundError(`${slug} version`, versionId);
1990
+ const { status: _status, ...data } = version.data;
1991
+ return this.saveGlobal(
1992
+ slug,
1993
+ config.drafts ? { ...data, status: "draft" } : data,
1994
+ options
1995
+ );
1996
+ }
1997
+ async globalVersionTarget(slug, options) {
1998
+ const config = this.global(slug);
1999
+ if (!versionLimit(config)) throw new QueryError(`"${slug}" has no versions`);
2000
+ await this.checkGlobalAccess(config, "update", guardOf(options));
2001
+ return config;
2002
+ }
2003
+ /** The global's pending draft when it has one (see `withDrafts`), otherwise `saved`. */
2004
+ async globalDraft(config, saved) {
2005
+ if (!this.separateDrafts(config) || saved.status !== "published") return saved;
2006
+ const version = (await this.versions.latest(globalParent(config.slug), [0])).get("0");
2007
+ if (version?.status !== "draft") return saved;
2008
+ return { ...version.data, updatedAt: version.createdAt, status: "draft" };
2009
+ }
2010
+ async saveGlobal(slug, raw, options, mode = "save") {
1668
2011
  const config = this.global(slug);
1669
2012
  const guard = guardOf(options);
1670
2013
  await this.checkGlobalAccess(config, "update", guard);
1671
- const raw = asObject(data, slug);
1672
2014
  const input = await filterInput(
1673
2015
  config.fields,
1674
2016
  raw,
1675
2017
  this.fieldChecker("update", guard, void 0, raw)
1676
2018
  );
1677
2019
  const existing = await this.db.findGlobal({ slug }) ?? {};
2020
+ const current = await this.globalDraft(config, existing);
1678
2021
  const merged = generateSlugs(
1679
2022
  config.fields,
1680
- applyDefaults(config.fields, mergeForUpdate(config.fields, existing, input))
2023
+ applyDefaults(config.fields, mergeForUpdate(config.fields, current, input))
1681
2024
  );
1682
- if (config.drafts) merged.status = input.status ?? existing.status ?? "draft";
2025
+ if (config.drafts) merged.status = input.status ?? current.status ?? "draft";
1683
2026
  const base = this.hookArgs(config, guard);
1684
2027
  let prepared = await this.prepare(config, merged, "update", void 0);
1685
2028
  prepared = await this.transform(
1686
2029
  config.hooks?.beforeChange,
1687
2030
  "data",
1688
- { ...base, operation: "update", originalDoc: existing },
2031
+ { ...base, operation: "update", originalDoc: current },
1689
2032
  prepared
1690
2033
  );
1691
- const doc = await this.db.updateGlobal({
1692
- slug,
1693
- data: { ...prepared, updatedAt: (/* @__PURE__ */ new Date()).toISOString() }
1694
- });
2034
+ const now = (/* @__PURE__ */ new Date()).toISOString();
2035
+ if (mode === "save" && this.separateDrafts(config) && existing.status === "published" && prepared.status === "draft") {
2036
+ const draft = { ...prepared, updatedAt: now };
2037
+ await this.saveVersion(config, globalParent(slug), 0, draft, guard);
2038
+ await this.notify(config.hooks?.afterChange, "afterChange", slug, {
2039
+ ...base,
2040
+ doc: draft,
2041
+ previousDoc: current,
2042
+ operation: "update"
2043
+ });
2044
+ return this.findGlobal(slug, { ...options, draft: true });
2045
+ }
2046
+ const doc = await this.db.updateGlobal({ slug, data: { ...prepared, updatedAt: now } });
2047
+ await this.saveVersion(config, globalParent(slug), 0, doc, guard);
1695
2048
  await this.notify(config.hooks?.afterChange, "afterChange", slug, {
1696
2049
  ...base,
1697
2050
  doc,
1698
- previousDoc: existing,
2051
+ previousDoc: current,
1699
2052
  operation: "update"
1700
2053
  });
1701
2054
  return this.findGlobal(slug, { ...options, draft: true });
@@ -1798,7 +2151,8 @@ var EasyCMS = class {
1798
2151
  limit: 0,
1799
2152
  page: 1
1800
2153
  });
1801
- return Promise.all(found.docs.map((d) => finish(target, d)));
2154
+ const docs2 = options.draft ? await this.withDrafts(target, found.docs) : found.docs;
2155
+ return Promise.all(docs2.map((d) => finish(target, d)));
1802
2156
  };
1803
2157
  const depth = Math.max(0, Math.min(MAX_DEPTH, Math.trunc(options.depth ?? DEFAULT_DEPTH)));
1804
2158
  const populated = await populate(load, this.config.collections, config.fields, docs, depth);
@@ -1849,6 +2203,7 @@ var EasyCMS = class {
1849
2203
  collection: collection2,
1850
2204
  data: { ...prepared, createdAt: now, updatedAt: now }
1851
2205
  });
2206
+ await this.saveVersion(config, collectionParent(collection2), doc.id, doc, guard);
1852
2207
  await this.notify(config.hooks?.afterChange, "afterChange", collection2, {
1853
2208
  ...base,
1854
2209
  doc,
@@ -1857,6 +2212,33 @@ var EasyCMS = class {
1857
2212
  const [out] = await this.output(config, [doc], hookGuard ?? guard, { ...options, draft: true });
1858
2213
  return out;
1859
2214
  }
2215
+ /** Drafts are kept as versions (instead of unpublishing) when both are enabled. */
2216
+ separateDrafts(config) {
2217
+ return config.drafts === true && versionLimit(config) !== void 0;
2218
+ }
2219
+ /** Replaces published documents that have a newer draft with that draft. */
2220
+ async withDrafts(config, docs) {
2221
+ if (!this.separateDrafts(config)) return docs;
2222
+ const published = docs.filter((d) => d.status === "published").map((d) => d.id);
2223
+ const latest = await this.versions.latest(collectionParent(config.slug), published);
2224
+ return docs.map((doc) => {
2225
+ const version = latest.get(String(doc.id));
2226
+ return version && doc.status === "published" && version.status === "draft" ? draftOf(doc, version) : doc;
2227
+ });
2228
+ }
2229
+ /** Records a version after a save, when the collection or global keeps versions. */
2230
+ async saveVersion(config, parent, id, doc, guard) {
2231
+ const max = versionLimit(config);
2232
+ if (max === void 0) return;
2233
+ await this.versions.save({
2234
+ parent,
2235
+ doc: id,
2236
+ status: config.drafts ? doc.status ?? "draft" : null,
2237
+ snapshot: snapshotOf(config.fields, doc),
2238
+ author: guard.user?.id ?? null,
2239
+ max
2240
+ });
2241
+ }
1860
2242
  hookArgs(config, guard) {
1861
2243
  return { user: guard.user, cms: this, slug: config.slug };
1862
2244
  }
@@ -2014,6 +2396,15 @@ var EasyCMS = class {
2014
2396
  return errors;
2015
2397
  }
2016
2398
  };
2399
+ function draftOf(doc, version) {
2400
+ return {
2401
+ ...version.data,
2402
+ id: doc.id,
2403
+ createdAt: doc.createdAt,
2404
+ updatedAt: version.createdAt,
2405
+ status: "draft"
2406
+ };
2407
+ }
2017
2408
  function draftWhere(config, draft, where) {
2018
2409
  if (!config.drafts || draft) return where;
2019
2410
  return andWhere(where, { status: { equals: "published" } });
@@ -2107,6 +2498,7 @@ async function collection(config, user) {
2107
2498
  const result = {
2108
2499
  slug: config.slug,
2109
2500
  drafts: config.drafts === true,
2501
+ versions: Boolean(config.versions),
2110
2502
  fields: await serializeFields(config.fields, new FieldAccessChecker("update", { user })),
2111
2503
  permissions: {
2112
2504
  read: await allowed(config.access?.read, user),
@@ -2123,6 +2515,7 @@ async function global(config, user) {
2123
2515
  const result = {
2124
2516
  slug: config.slug,
2125
2517
  drafts: config.drafts === true,
2518
+ versions: Boolean(config.versions),
2126
2519
  fields: await serializeFields(config.fields, new FieldAccessChecker("update", { user })),
2127
2520
  permissions: {
2128
2521
  read: await allowed(config.access?.read, user),
@@ -2369,9 +2762,10 @@ async function route(cms, ctx, method, segments, options) {
2369
2762
  throw new HttpError("Not found", 404);
2370
2763
  }
2371
2764
  if (first === "globals") {
2372
- if (!second || third !== void 0) throw new HttpError("Not found", 404);
2765
+ if (!second) throw new HttpError("Not found", 404);
2373
2766
  if (!cms.config.globals.some((g) => g.slug === second))
2374
2767
  throw new HttpError(`Unknown global "${second}"`, 404);
2768
+ if (third !== void 0) return globalAction(cms, ctx, method, second, segments.slice(2));
2375
2769
  if (method === "GET")
2376
2770
  return { body: await cms.findGlobal(second, { ...access, ...parseDepth(ctx.url), draft }) };
2377
2771
  if (method === "POST") {
@@ -2395,7 +2789,9 @@ async function route(cms, ctx, method, segments, options) {
2395
2789
  if (INTERNAL_COLLECTIONS.has(collection2) || !cms.config.collections.some((c) => c.slug === collection2)) {
2396
2790
  throw new HttpError(`Unknown collection "${collection2}"`, 404);
2397
2791
  }
2398
- if (third !== void 0) throw new HttpError("Not found", 404);
2792
+ if (second !== void 0 && third !== void 0) {
2793
+ return documentAction(cms, ctx, method, collection2, second, segments.slice(2));
2794
+ }
2399
2795
  if (second === void 0) {
2400
2796
  if (method === "GET") {
2401
2797
  const query = parseListQuery(ctx.url);
@@ -2429,6 +2825,64 @@ async function route(cms, ctx, method, segments, options) {
2429
2825
  }
2430
2826
  throw methodNotAllowed(ctx, "GET, PATCH, DELETE");
2431
2827
  }
2828
+ async function documentAction(cms, ctx, method, collection2, id, path) {
2829
+ const access = { overrideAccess: false, user: ctx.user };
2830
+ const depth = parseDepth(ctx.url);
2831
+ const [action, versionId, extra] = path;
2832
+ if (action === "versions" && versionId === void 0) {
2833
+ if (method !== "GET") throw methodNotAllowed(ctx, "GET");
2834
+ return { body: await cms.findVersions(collection2, id, { ...access, ...parsePage(ctx.url) }) };
2835
+ }
2836
+ if (action === "versions" && versionId !== void 0 && extra === void 0) {
2837
+ if (method !== "GET") throw methodNotAllowed(ctx, "GET");
2838
+ const version = await cms.findVersion(collection2, id, versionId, { ...access, ...depth });
2839
+ if (!version) throw new NotFoundError(`${collection2} version`, versionId);
2840
+ return { body: version };
2841
+ }
2842
+ if (action === "versions" && versionId !== void 0 && extra === "restore" && path.length === 3) {
2843
+ if (method !== "POST") throw methodNotAllowed(ctx, "POST");
2844
+ return { body: await cms.restoreVersion(collection2, id, versionId, { ...access, ...depth }) };
2845
+ }
2846
+ if (path.length === 1 && (action === "unpublish" || action === "discard-draft")) {
2847
+ if (method !== "POST") throw methodNotAllowed(ctx, "POST");
2848
+ const options = { ...access, ...depth };
2849
+ return {
2850
+ body: action === "unpublish" ? await cms.unpublish(collection2, id, options) : await cms.discardDraft(collection2, id, options)
2851
+ };
2852
+ }
2853
+ throw new HttpError("Not found", 404);
2854
+ }
2855
+ async function globalAction(cms, ctx, method, slug, path) {
2856
+ const access = { overrideAccess: false, user: ctx.user };
2857
+ const depth = parseDepth(ctx.url);
2858
+ const [action, versionId, extra] = path;
2859
+ if (action === "versions" && versionId === void 0) {
2860
+ if (method !== "GET") throw methodNotAllowed(ctx, "GET");
2861
+ return { body: await cms.findGlobalVersions(slug, { ...access, ...parsePage(ctx.url) }) };
2862
+ }
2863
+ if (action === "versions" && versionId !== void 0 && extra === void 0) {
2864
+ if (method !== "GET") throw methodNotAllowed(ctx, "GET");
2865
+ const version = await cms.findGlobalVersion(slug, versionId, { ...access, ...depth });
2866
+ if (!version) throw new NotFoundError(`${slug} version`, versionId);
2867
+ return { body: version };
2868
+ }
2869
+ if (action === "versions" && versionId !== void 0 && extra === "restore" && path.length === 3) {
2870
+ if (method !== "POST") throw methodNotAllowed(ctx, "POST");
2871
+ return { body: await cms.restoreGlobalVersion(slug, versionId, { ...access, ...depth }) };
2872
+ }
2873
+ if (path.length === 1 && (action === "unpublish" || action === "discard-draft")) {
2874
+ if (method !== "POST") throw methodNotAllowed(ctx, "POST");
2875
+ const options = { ...access, ...depth };
2876
+ return {
2877
+ body: action === "unpublish" ? await cms.unpublishGlobal(slug, options) : await cms.discardGlobalDraft(slug, options)
2878
+ };
2879
+ }
2880
+ throw new HttpError("Not found", 404);
2881
+ }
2882
+ function parsePage(url) {
2883
+ const { page, limit } = parseListQuery(url);
2884
+ return { page, limit };
2885
+ }
2432
2886
  function methodNotAllowed(ctx, allow) {
2433
2887
  ctx.headers.set("allow", allow);
2434
2888
  return new HttpError("Method not allowed", 405);
@@ -2734,6 +3188,7 @@ export {
2734
3188
  DEFAULT_API_PATH,
2735
3189
  DEFAULT_DEPTH,
2736
3190
  DEFAULT_MAX_FILE_SIZE,
3191
+ DEFAULT_MAX_VERSIONS,
2737
3192
  DEFAULT_TOKEN_EXPIRATION,
2738
3193
  EXTENSIONS,
2739
3194
  EasyCMS,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@easy-cms/core",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Embedded, code-first headless CMS for Nuxt and Next.js",
5
5
  "license": "MIT",
6
6
  "type": "module",