@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 +77 -1
- package/dist/index.js +479 -24
- package/package.json +1 -1
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
|
|
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: [
|
|
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
|
|
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 [
|
|
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,
|
|
1851
|
+
let merged = mergeForUpdate(config.fields, current, filtered);
|
|
1594
1852
|
if (config.drafts)
|
|
1595
|
-
merged.status = Object.hasOwn(filtered, "status") ? filtered.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:
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
|
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,
|
|
2023
|
+
applyDefaults(config.fields, mergeForUpdate(config.fields, current, input))
|
|
1681
2024
|
);
|
|
1682
|
-
if (config.drafts) merged.status = input.status ??
|
|
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:
|
|
2031
|
+
{ ...base, operation: "update", originalDoc: current },
|
|
1689
2032
|
prepared
|
|
1690
2033
|
);
|
|
1691
|
-
const
|
|
1692
|
-
|
|
1693
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
|
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 (
|
|
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,
|