From 9829b40bda4734caec908d618578d55eb4656f0b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 07:13:36 +0000 Subject: [PATCH 01/17] =?UTF-8?q?feat(content):=20=E2=9C=A8=20scaffold=20v?= =?UTF-8?q?isibility,=20duplication=20and=20id=20strategy=20contracts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the definition-level configuration and resolved shapes for record visibility, duplication and the primary key strategy, the central content ID helpers, the can_hide permission and the duplicated/hidden/unhidden event contracts. Behaviour lands in follow-up commits. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VpG3D33ZqXEYjN2N6eNcMK --- packages/vitnode/src/content/const.ts | 12 ++ packages/vitnode/src/content/define-fields.ts | 9 + .../vitnode/src/content/define-lifecycle.ts | 84 ++++++++ packages/vitnode/src/content/define-shared.ts | 4 + packages/vitnode/src/content/define.ts | 79 ++++++- packages/vitnode/src/content/events.ts | 75 ++++++- packages/vitnode/src/content/ids.ts | 196 ++++++++++++++++++ packages/vitnode/src/content/index.ts | 29 +++ packages/vitnode/src/content/registry.ts | 10 + packages/vitnode/src/content/server/emit.ts | 9 + packages/vitnode/src/content/types.ts | 78 ++++++- packages/vitnode/src/locales/en.json | 1 + 12 files changed, 579 insertions(+), 7 deletions(-) create mode 100644 packages/vitnode/src/content/define-lifecycle.ts create mode 100644 packages/vitnode/src/content/ids.ts diff --git a/packages/vitnode/src/content/const.ts b/packages/vitnode/src/content/const.ts index 927454c7c2..d6d0840e59 100644 --- a/packages/vitnode/src/content/const.ts +++ b/packages/vitnode/src/content/const.ts @@ -4,6 +4,17 @@ export const CONTENT_PUBLICATION_FIELDS = ["status", "publishedAt"] as const; export const CONTENT_EDITORIAL_FIELDS = ["version"] as const; +/** The two system columns `visibility: { enabled: true }` adds to the base table. */ +export const CONTENT_VISIBILITY_FIELDS = ["hiddenAt", "hiddenBy"] as const; + +/** + * How a content type's primary key is generated. `serial` is the default and the + * only strategy a content type had before; see `ids.ts`. + */ +export const CONTENT_ID_STRATEGIES = ["serial", "uuid", "bigint"] as const; + +export const CONTENT_DEFAULT_ID_STRATEGY = "serial"; + export const CONTENT_TRANSLATION_SYSTEM_FIELDS = [ "itemId", "languageId", @@ -382,6 +393,7 @@ export const CONTENT_PERMISSIONS = { create: "can_create", delete: "can_delete", edit: "can_edit", + hide: "can_hide", publish: "can_publish", restore: "can_restore", view: "can_view", diff --git a/packages/vitnode/src/content/define-fields.ts b/packages/vitnode/src/content/define-fields.ts index 18db05bc70..25d7536955 100644 --- a/packages/vitnode/src/content/define-fields.ts +++ b/packages/vitnode/src/content/define-fields.ts @@ -16,6 +16,7 @@ import { editorialFields, publicationFields, systemFields, + visibilityFields, } from "./define-shared"; import { ContentEngineError } from "./errors"; import { scalarFieldConstraintIssue } from "./field-constraints"; @@ -63,6 +64,7 @@ export const assertFieldName = ( name: string, publication: boolean, editorial: boolean, + visibility = false, ): void => { if (systemFields.includes(name)) { throw new ContentEngineError( @@ -85,6 +87,13 @@ export const assertFieldName = ( ); } + if (visibility && visibilityFields.includes(name)) { + throw new ContentEngineError( + `"${name}" is generated by \`visibility\` and cannot also be declared as a field. Rename the field, or drop \`visibility\`.`, + { contentTypeId: id }, + ); + } + if (!CONTENT_FIELD_NAME_PATTERN.test(name)) { throw new ContentEngineError( `Field "${name}" must be camelCase and start with a lowercase letter.`, diff --git a/packages/vitnode/src/content/define-lifecycle.ts b/packages/vitnode/src/content/define-lifecycle.ts new file mode 100644 index 0000000000..7e6426a7d4 --- /dev/null +++ b/packages/vitnode/src/content/define-lifecycle.ts @@ -0,0 +1,84 @@ +import type { ContentIdStrategy } from "./ids"; +import type { + ContentDuplicationInput, + ContentFieldMap, + ResolvedContentAdminConfig, + ResolvedContentDuplicationConfig, + ResolvedContentVisibilityConfig, +} from "./types"; + +import { CONTENT_DEFAULT_ID_STRATEGY, CONTENT_ID_STRATEGIES } from "./const"; +import { ContentEngineError } from "./errors"; + +export const resolveIdStrategy = ( + id: string, + idStrategy: unknown, +): ContentIdStrategy => { + if (idStrategy === undefined) return CONTENT_DEFAULT_ID_STRATEGY; + + if (!(CONTENT_ID_STRATEGIES as readonly unknown[]).includes(idStrategy)) { + throw new ContentEngineError( + `idStrategy must be one of ${CONTENT_ID_STRATEGIES.map(value => `"${value}"`).join(", ")}, not ${JSON.stringify(idStrategy)}.`, + { contentTypeId: id }, + ); + } + + return idStrategy as ContentIdStrategy; +}; + +export const resolveVisibility = ( + id: string, + visibility: boolean | undefined, + publication: boolean, +): ResolvedContentVisibilityConfig => { + if (visibility !== undefined && typeof visibility !== "boolean") { + throw new ContentEngineError( + "visibility is `true` or `false`. It has no options of its own.", + { contentTypeId: id }, + ); + } + if (visibility !== true) return { enabled: false }; + + // Hiding is "unavailable publicly regardless of publication status". Without + // publication there is no public side to take a record off. + if (!publication) { + throw new ContentEngineError( + "visibility needs `publication: true`. Hiding takes a record off the public site, and a content type without publication has none.", + { contentTypeId: id }, + ); + } + + return { enabled: true }; +}; + +export const resolveDuplication = ( + id: string, + duplication: ContentDuplicationInput | undefined, + fields: ContentFieldMap, + admin: ResolvedContentAdminConfig, +): ResolvedContentDuplicationConfig => { + if (duplication === undefined || duplication === false) { + return { enabled: false, titleSuffixField: null }; + } + + const titleSuffix = + duplication === true ? undefined : duplication.titleSuffix; + + if (titleSuffix === false) { + return { enabled: true, titleSuffixField: null }; + } + + const titleField = admin.titleField; + if (titleField !== null && fields[titleField]?.kind === "text") { + return { enabled: true, titleSuffixField: titleField }; + } + + if (titleSuffix === true) { + throw new ContentEngineError( + `duplication.titleSuffix needs \`admin.titleField\` to be a text field, and "${String(titleField)}" is not one. Drop \`titleSuffix\` or point \`admin.titleField\` at a text field.`, + { contentTypeId: id }, + ); + } + + return { enabled: true, titleSuffixField: null }; +}; diff --git a/packages/vitnode/src/content/define-shared.ts b/packages/vitnode/src/content/define-shared.ts index 7fc3298f9e..18b11a7240 100644 --- a/packages/vitnode/src/content/define-shared.ts +++ b/packages/vitnode/src/content/define-shared.ts @@ -4,6 +4,7 @@ import { CONTENT_EDITORIAL_FIELDS, CONTENT_PUBLICATION_FIELDS, CONTENT_SYSTEM_FIELDS, + CONTENT_VISIBILITY_FIELDS, } from "./const"; import { ContentEngineError } from "./errors"; import { splitContentFieldPath } from "./paths"; @@ -21,6 +22,7 @@ export const EXPLICIT_SEARCHABLE_KINDS = new Set< export const systemFields: readonly string[] = CONTENT_SYSTEM_FIELDS; export const publicationFields: readonly string[] = CONTENT_PUBLICATION_FIELDS; export const editorialFields: readonly string[] = CONTENT_EDITORIAL_FIELDS; +export const visibilityFields: readonly string[] = CONTENT_VISIBILITY_FIELDS; export const assertKnownColumns = ( id: string, @@ -87,6 +89,7 @@ const CONTENT_OPTION_PATHS: readonly (readonly string[])[] = [ ["delivery"], ["delivery", "redirects"], ["delivery", "sitemap"], + ["duplication"], ["editorial"], ["editorial", "preview"], ["editorial", "scheduling"], @@ -94,6 +97,7 @@ const CONTENT_OPTION_PATHS: readonly (readonly string[])[] = [ ["publicApi"], ["publication"], ["search"], + ["visibility"], ]; const CONTENT_OPTIONS_WITH_REQUIRED_KEYS = new Set([ diff --git a/packages/vitnode/src/content/define.ts b/packages/vitnode/src/content/define.ts index b74ff3a5ec..099c138f8a 100644 --- a/packages/vitnode/src/content/define.ts +++ b/packages/vitnode/src/content/define.ts @@ -1,3 +1,4 @@ +import type { ContentIdStrategy } from "./ids"; import type { AnyContentTypeDefinition, ContentAdminConfig, @@ -7,6 +8,8 @@ import type { ContentDeliveryInput, ContentDeliveryNoIndexField, ContentDeliveryTitleField, + ContentDuplicationEnabled, + ContentDuplicationInput, ContentEditorialEnabled, ContentEditorialInput, ContentFieldMap, @@ -26,10 +29,12 @@ import type { ContentSearchTitleField, ContentTypeDefinition, ResolvedContentDeliveryConfig, + ResolvedContentDuplicationConfig, ResolvedContentEditorialConfig, ResolvedContentLocalizationConfig, ResolvedContentPublicApiConfig, ResolvedContentSearchConfig, + ResolvedContentVisibilityConfig, } from "./types"; import { contentEntityKey } from "./admin/labels"; @@ -52,6 +57,11 @@ import { assertSlugSources, bindSelfRelations, } from "./define-fields"; +import { + resolveDuplication, + resolveIdStrategy, + resolveVisibility, +} from "./define-lifecycle"; import { resolvePublicApi } from "./define-public-api"; import { resolveSearch } from "./define-search"; import { @@ -60,6 +70,7 @@ import { editorialFields, publicationFields, systemFields, + visibilityFields, } from "./define-shared"; import { resolveContentDelivery } from "./delivery"; import { ContentEngineError } from "./errors"; @@ -193,12 +204,21 @@ export const defineContentType = < ContentDeliveryNoIndexField > = false, TPublicPath extends string = string, + // Inferred as whole objects for the same reason `TSearch` and `TEditorial` + // are: an intersection member is not an inference site, and every conditional + // that decides whether `hiddenAt`, `hide()` or `duplicate()` exist reads the + // `enabled` literal. + TVisibility extends boolean = false, + TDuplication extends ContentDuplicationInput = false, + TIdStrategy extends ContentIdStrategy = "serial", >({ admin = {}, delivery, + duplication, editorial, fields, id, + idStrategy, indexes = [], liveEditing, localization, @@ -206,6 +226,7 @@ export const defineContentType = < publication, search, tableName, + visibility, }: { admin?: ContentAdminConfig< TFields, @@ -215,9 +236,18 @@ export const defineContentType = < delivery?: TDelivery; + /** Opts into "Duplicate": a draft copy of one record. Omit to keep it off. */ + duplication?: TDuplication; + editorial?: TEditorial; fields: TFields; id: TId; + /** + * How the primary key is generated: `serial` (the default, a number), `uuid` + * or `bigint` (both strings in every JSON-facing API). Changing it on an + * existing content type is a data migration, not a config change. + */ + idStrategy?: TIdStrategy; indexes?: ContentIndexInput< TFields, TPublication, @@ -238,6 +268,8 @@ export const defineContentType = < search?: TSearch; tableName: string; + /** Opts into record-level hiding. Needs `publication`. */ + visibility?: TVisibility; }): ContentTypeDefinition< TId, TFields, @@ -250,7 +282,10 @@ export const defineContentType = < ContentSchedulingEnabled, ContentLocalizationEnabled, ContentDeliveryEnabled, - TPublicPath + TPublicPath, + TVisibility, + ContentDuplicationEnabled, + TIdStrategy > => { if (!CONTENT_ID_PATTERN.test(id)) { throw new ContentEngineError( @@ -298,11 +333,13 @@ export const defineContentType = < assertContentOptionShapes(id, { admin, delivery, + duplication, editorial, localization, publicApi, publication, search, + visibility, }); if (publication !== undefined && typeof publication !== "boolean") { @@ -328,9 +365,22 @@ export const defineContentType = < { contentTypeId: id }, ); } + const resolvedIdStrategy = resolveIdStrategy(id, idStrategy); + const resolvedVisibility = resolveVisibility( + id, + visibility, + publicationEnabled, + ); + const visibilityEnabled = resolvedVisibility.enabled; for (const name of fieldNames) { - assertFieldName(id, name, publicationEnabled, editorialEnabled); + assertFieldName( + id, + name, + publicationEnabled, + editorialEnabled, + visibilityEnabled, + ); assertFieldKind(id, name, fieldMap[name]); assertField(id, name, fieldMap[name]); } @@ -368,6 +418,7 @@ export const defineContentType = < ...systemFields, ...(publicationEnabled ? publicationFields : []), ...(editorialEnabled ? editorialFields : []), + ...(visibilityEnabled ? visibilityFields : []), ]); const resolvedIndexes = resolveContentIndexes({ contentTypeId: id, @@ -467,6 +518,14 @@ export const defineContentType = < publication: publicationEnabled, }); + const resolvedDuplication = resolveDuplication( + id, + // The `{ enabled: false }` arm exists only so an explicit literal typechecks. + duplication, + fieldMap, + resolvedAdmin, + ); + const definition: ContentTypeDefinition< TId, TFields, @@ -479,13 +538,19 @@ export const defineContentType = < ContentSchedulingEnabled, ContentLocalizationEnabled, ContentDeliveryEnabled, - TPublicPath + TPublicPath, + TVisibility, + ContentDuplicationEnabled, + TIdStrategy > = { admin: resolvedAdmin, advanced: resolvedAdvanced, delivery: resolvedDelivery as ResolvedContentDeliveryConfig< ContentDeliveryEnabled >, + duplication: resolvedDuplication as ResolvedContentDuplicationConfig< + ContentDuplicationEnabled + >, editorial: resolvedEditorial as ResolvedContentEditorialConfig< ContentEditorialEnabled, ContentPreviewEnabled, @@ -494,6 +559,7 @@ export const defineContentType = < // The rebound copy, so a self-relation resolves rather than throwing. fields: fieldMap as unknown as TFields, id, + idStrategy: resolvedIdStrategy as TIdStrategy, indexes: resolvedIndexes, liveEditing: { enabled: liveEditing === true }, localization: resolvedLocalization as ResolvedContentLocalizationConfig< @@ -521,7 +587,10 @@ export const defineContentType = < ContentSchedulingEnabled, ContentLocalizationEnabled, ContentDeliveryEnabled, - TPublicPath + TPublicPath, + TVisibility, + ContentDuplicationEnabled, + TIdStrategy > >({ admin: resolvedAdmin, @@ -536,6 +605,8 @@ export const defineContentType = < ContentSearchEnabled >, tableName, + visibility: + resolvedVisibility as ResolvedContentVisibilityConfig, }; return definition; diff --git a/packages/vitnode/src/content/events.ts b/packages/vitnode/src/content/events.ts index 43e8eb5acd..b9eeb33c91 100644 --- a/packages/vitnode/src/content/events.ts +++ b/packages/vitnode/src/content/events.ts @@ -1,3 +1,4 @@ +import type { ContentIdOf } from "./ids"; import type { ContentFieldName, ContentLocalizedFieldName } from "./types"; export type ContentEventAction = @@ -5,6 +6,8 @@ export type ContentEventAction = | "deleted" | "delivery_redirect_created" | "delivery_slug_changed" + | "duplicated" + | "hidden" | "published" | "restored" | "schedule_cancelled" @@ -15,6 +18,7 @@ export type ContentEventAction = | "translation_restored" | "translation_unpublished" | "translation_updated" + | "unhidden" | "unpublished" | "updated"; @@ -22,6 +26,46 @@ export interface ContentCreatedPayload { contentId: number; } +/** + * `created` on a content type with `duplication` enabled. `duplicatedFromId` is + * set when the record was created by `duplicate()`: a listener that only cares + * about new records ignores it, one that wants to tell copies apart reads it + * here instead of correlating with the `duplicated` event. + */ +export interface ContentDuplicableCreatedPayload { + contentId: TId; + duplicatedFromId?: TId; +} + +/** + * Emitted once per successful `duplicate()`, after the copy's ordinary + * `created` event (and its `translation_created` events). It is a notification + * only: indexing and cache work already ran for `created`, so a listener must not + * repeat it here. + */ +export interface ContentDuplicatedPayload { + /** The staff member who asked for the copy; `null` for the system or an API key. */ + actorUserId: null | number; + /** The new record. */ + contentId: TId; + contentTypeId: string; + /** The record that was copied. */ + sourceId: TId; +} + +/** Emitted when a visible record is hidden. Never for a no-op. */ +export interface ContentHiddenPayload { + actorUserId: null | number; + contentId: TId; + hiddenAt: Date; +} + +/** Emitted when a hidden record is made visible again. Never for a no-op. */ +export interface ContentUnhiddenPayload { + actorUserId: null | number; + contentId: TId; +} + export interface ContentDeletedPayload { contentId: number; } @@ -296,6 +340,28 @@ type ContentDeliveryEventsFor = > : Record; +/** `duplicated`, only for a content type with `duplication` enabled. */ +type ContentDuplicationEventsFor = + TDefinition extends { duplication: { enabled: true } } + ? Record< + `content.${TDefinition["id"]}.duplicated`, + ContentDuplicatedPayload> + > + : Record; + +/** `hidden` and `unhidden`, only for a content type with `visibility` enabled. */ +type ContentVisibilityEventsFor = + TDefinition extends { visibility: { enabled: true } } + ? Record< + `content.${TDefinition["id"]}.hidden`, + ContentHiddenPayload> + > & + Record< + `content.${TDefinition["id"]}.unhidden`, + ContentUnhiddenPayload> + > + : Record; + /** * The events a content type emits, as a literal-keyed map. * @@ -316,10 +382,17 @@ type ContentDeliveryEventsFor = */ export type ContentEventsFor = ContentDeliveryEventsFor & + ContentDuplicationEventsFor & ContentEditorialEventsFor & ContentLocalizationEventsFor & ContentPublicationEventsFor & - Record<`content.${TDefinition["id"]}.created`, ContentCreatedPayload> & + ContentVisibilityEventsFor & + Record< + `content.${TDefinition["id"]}.created`, + TDefinition extends { duplication: { enabled: true } } + ? ContentDuplicableCreatedPayload> + : ContentCreatedPayload + > & Record<`content.${TDefinition["id"]}.deleted`, ContentDeletedPayload> & Record< `content.${TDefinition["id"]}.updated`, diff --git a/packages/vitnode/src/content/ids.ts b/packages/vitnode/src/content/ids.ts new file mode 100644 index 0000000000..d73af1a2cb --- /dev/null +++ b/packages/vitnode/src/content/ids.ts @@ -0,0 +1,196 @@ +import { z } from "zod"; + +import type { CONTENT_ID_STRATEGIES } from "./const"; + +/** How a content type's primary key is generated. Defaults to `serial`. */ +export type ContentIdStrategy = (typeof CONTENT_ID_STRATEGIES)[number]; + +/** + * The identifier of one record under a strategy, as every JSON-facing surface + * carries it: a `serial` is a JavaScript number, and a `uuid` or a `bigint` is a + * string. A `bigint` stays a canonical decimal string end to end - it never + * passes through `Number`, which would round anything above 2^53. + */ +export type ContentIdOfStrategy = TStrategy extends "serial" + ? number + : string; + +/** + * The identifier type of one content type. Concrete definitions resolve to + * exactly one of `number` or `string`; the erased `AnyContentTypeDefinition` + * resolves to the union, which is what generic engine code handles. + */ +export type ContentIdOf = TDefinition extends { + idStrategy: infer TStrategy; +} + ? ContentIdOfStrategy + : number; + +/** Any record identifier, under any strategy. */ +export type ContentId = number | string; + +/** The largest value a Postgres `integer` (and so a `serial`) can hold. */ +export const CONTENT_SERIAL_MAX = 2_147_483_647; + +/** The largest value a Postgres `bigint` can hold, as a decimal string. */ +export const CONTENT_BIGINT_MAX = "9223372036854775807"; + +const SERIAL_PATTERN = /^[1-9][0-9]{0,9}$/; +const BIGINT_PATTERN = /^[1-9][0-9]{0,18}$/; +/** Canonical form only: lowercase, hyphenated, 8-4-4-4-12. */ +const UUID_PATTERN = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; + +const withinBigint = (value: string): boolean => + value.length < CONTENT_BIGINT_MAX.length || + (value.length === CONTENT_BIGINT_MAX.length && value <= CONTENT_BIGINT_MAX); + +/** + * Reads an identifier from untrusted input - a path segment, a query value, a + * JSON body - and returns it in its strategy's canonical representation, or + * `null` when it is malformed, out of range or not canonical. + * + * - `serial`: a positive integer up to 2147483647, as a number or as decimal + * digits without a sign, leading zeros or whitespace. + * - `bigint`: a positive integer up to 9223372036854775807, as decimal digits + * without a sign, leading zeros or whitespace. A JavaScript number is accepted + * only while it is a safe integer, since a larger one has already been rounded. + * - `uuid`: lowercase hyphenated 8-4-4-4-12 hex. Uppercase and braces are refused + * rather than folded, so one record has exactly one spelling in URLs and caches. + */ +export const parseContentId = ( + strategy: ContentIdStrategy, + value: unknown, +): ContentId | null => { + if (strategy === "uuid") { + return typeof value === "string" && UUID_PATTERN.test(value) ? value : null; + } + + if (strategy === "bigint") { + if (typeof value === "number") { + return Number.isSafeInteger(value) && value > 0 ? String(value) : null; + } + if (typeof value === "bigint") { + const text = value.toString(); + + return value > 0n && withinBigint(text) ? text : null; + } + + return typeof value === "string" && + BIGINT_PATTERN.test(value) && + withinBigint(value) + ? value + : null; + } + + if (typeof value === "number") { + return Number.isInteger(value) && value > 0 && value <= CONTENT_SERIAL_MAX + ? value + : null; + } + if (typeof value === "string" && SERIAL_PATTERN.test(value)) { + const parsed = Number(value); + + return parsed <= CONTENT_SERIAL_MAX ? parsed : null; + } + + return null; +}; + +/** {@link parseContentId} for one definition, narrowed to its own id type. */ +export const parseContentIdOf = < + TDefinition extends { idStrategy: ContentIdStrategy }, +>( + definition: TDefinition, + value: unknown, +): ContentIdOf | null => + parseContentId( + definition.idStrategy, + value, + ) as ContentIdOf | null; + +/** + * The storage key of an identifier in the shared tables that reference records + * of any content type (revisions, schedules, slug history, the search index), + * in cache tags and in search document ids. Decimal digits for `serial` and + * `bigint`, the canonical spelling for `uuid` - so an existing serial record's + * key is exactly the digits it always had. + */ +export const contentIdKey = (id: ContentId): string => String(id); + +/** The inverse of {@link contentIdKey}: a stored key back to the API representation. */ +export const contentIdFromKey = ( + strategy: ContentIdStrategy, + key: string, +): ContentId | null => parseContentId(strategy, key); + +/** Whether a value is already a valid identifier under `strategy`. */ +export const isContentId = ( + strategy: ContentIdStrategy, + value: unknown, +): value is ContentId => { + const parsed = parseContentId(strategy, value); + + return parsed !== null && parsed === value; +}; + +/** + * The Zod schema of one identifier as it crosses JSON: a positive integer for + * `serial`, a decimal string for `bigint`, a canonical UUID for `uuid`. + */ +export const contentIdSchema = ( + strategy: ContentIdStrategy, +): z.ZodType => + strategy === "serial" + ? z.number().int().positive().max(CONTENT_SERIAL_MAX) + : strategy === "bigint" + ? z + .string() + .regex(BIGINT_PATTERN, "Expected a positive decimal bigint.") + .refine(withinBigint, "Out of range for a bigint identifier.") + : z.string().regex(UUID_PATTERN, "Expected a lowercase UUID."); + +/** + * The Zod schema of one identifier read from a path segment or a query string, + * where every value arrives as text. A `serial` is coerced to a number. + */ +export const contentIdParamSchema = ( + strategy: ContentIdStrategy, +): z.ZodType => + z.string().transform((value, ctx) => { + const parsed = parseContentId(strategy, value); + if (parsed === null) { + ctx.addIssue({ + code: "custom", + message: + strategy === "uuid" + ? "Expected a lowercase UUID." + : "Expected a positive integer identifier.", + }); + + return z.NEVER; + } + + return parsed; + }); + +/** + * Orders two identifiers of the same strategy the way Postgres orders the + * column: numerically for `serial` and `bigint` (by length, then digits, since + * both are canonical), lexically for `uuid`. + */ +export const compareContentIds = ( + strategy: ContentIdStrategy, + a: ContentId, + b: ContentId, +): number => { + if (strategy === "serial") return Number(a) - Number(b); + + const left = String(a); + const right = String(b); + if (strategy === "bigint" && left.length !== right.length) { + return left.length - right.length; + } + + return left < right ? -1 : left > right ? 1 : 0; +}; diff --git a/packages/vitnode/src/content/index.ts b/packages/vitnode/src/content/index.ts index c90c62355c..b611b9f1d6 100644 --- a/packages/vitnode/src/content/index.ts +++ b/packages/vitnode/src/content/index.ts @@ -215,8 +215,11 @@ export type { ContentDeletedPayload, ContentDeliveryRedirectCreatedPayload, ContentDeliverySlugChangedPayload, + ContentDuplicableCreatedPayload, + ContentDuplicatedPayload, ContentEventAction, ContentEventsFor, + ContentHiddenPayload, ContentPublishedPayload, ContentRestoredPayload, ContentTranslationCreatedPayload, @@ -226,6 +229,7 @@ export type { ContentTranslationRestoredPayload, ContentTranslationUnpublishedPayload, ContentTranslationUpdatedPayload, + ContentUnhiddenPayload, ContentUnpublishedPayload, ContentUpdatedPayload, } from "./events"; @@ -254,6 +258,24 @@ export type { ContentFileRejection, } from "./files"; export { clampWithFingerprint, fingerprint } from "./hash"; +export { + compareContentIds, + CONTENT_BIGINT_MAX, + CONTENT_SERIAL_MAX, + contentIdFromKey, + contentIdKey, + contentIdParamSchema, + contentIdSchema, + isContentId, + parseContentId, + parseContentIdOf, +} from "./ids"; +export type { + ContentId, + ContentIdOf, + ContentIdOfStrategy, + ContentIdStrategy, +} from "./ids"; export { contentIndexName, contentTranslationPrimaryKeyName, @@ -389,6 +411,9 @@ export type { ContentDeliverySeoConfig, ContentDeliverySitemapConfig, ContentDeliveryTitleField, + ContentDuplicationConfig, + ContentDuplicationEnabled, + ContentDuplicationInput, ContentEditorialConfig, ContentEditorialEnabled, ContentEditorialField, @@ -453,9 +478,11 @@ export type { ContentUserField, ContentValuesOf, DeliverableContentTypeDefinition, + DuplicableContentTypeDefinition, EditorialContentTypeDefinition, FilterableContentFieldKind, FilterableContentFieldName, + HideableContentTypeDefinition, LocalizedContentTypeDefinition, PreviewableContentTypeDefinition, PublicContentTypeDefinition, @@ -463,12 +490,14 @@ export type { ResolvedContentAdminConfig, ResolvedContentDeliveryConfig, ResolvedContentDeliverySeoConfig, + ResolvedContentDuplicationConfig, ResolvedContentEditorialConfig, ResolvedContentIndex, ResolvedContentLocalizationConfig, ResolvedContentPublicApiConfig, ResolvedContentPublicationConfig, ResolvedContentSearchConfig, + ResolvedContentVisibilityConfig, SchedulableContentTypeDefinition, SearchableContentTypeDefinition, } from "./types"; diff --git a/packages/vitnode/src/content/registry.ts b/packages/vitnode/src/content/registry.ts index d12c050b5c..70c382bed4 100644 --- a/packages/vitnode/src/content/registry.ts +++ b/packages/vitnode/src/content/registry.ts @@ -397,6 +397,16 @@ export const contentPermissionEntries = ( }, ] : []), + // Hiding takes a record off the public site without unpublishing it, so it + // is gated like publishing: on its own, and for both directions. + ...(definition?.visibility.enabled + ? [ + { + dependsOn: [CONTENT_PERMISSIONS.view], + permission: CONTENT_PERMISSIONS.hide, + }, + ] + : []), // Restoring is the one generated operation that rewrites many fields at once // from a source the editor did not type, so it gets its own gate. It depends // on `can_edit` rather than `can_view`: somebody who may not edit must not diff --git a/packages/vitnode/src/content/server/emit.ts b/packages/vitnode/src/content/server/emit.ts index 5d6d67145c..3ea9680dd5 100644 --- a/packages/vitnode/src/content/server/emit.ts +++ b/packages/vitnode/src/content/server/emit.ts @@ -8,7 +8,10 @@ import type { import type { ContentCreatedPayload, ContentDeletedPayload, + ContentDuplicableCreatedPayload, + ContentDuplicatedPayload, ContentEventAction, + ContentHiddenPayload, ContentPublishedPayload, ContentTranslationCreatedPayload, ContentTranslationDeletedPayload, @@ -16,9 +19,11 @@ import type { ContentTranslationRestoredPayload, ContentTranslationUnpublishedPayload, ContentTranslationUpdatedPayload, + ContentUnhiddenPayload, ContentUnpublishedPayload, ContentUpdatedPayload, } from "../events"; +import type { ContentId } from "../ids"; import type { AnyContentTypeDefinition } from "../types"; import { contentEventName } from "../events"; @@ -26,6 +31,9 @@ import { contentEventName } from "../events"; type ContentPayload = | ContentCreatedPayload | ContentDeletedPayload + | ContentDuplicableCreatedPayload + | ContentDuplicatedPayload + | ContentHiddenPayload | ContentPublishedPayload | ContentTranslationCreatedPayload | ContentTranslationDeletedPayload @@ -33,6 +41,7 @@ type ContentPayload = | ContentTranslationRestoredPayload | ContentTranslationUnpublishedPayload | ContentTranslationUpdatedPayload + | ContentUnhiddenPayload | ContentUnpublishedPayload | ContentUpdatedPayload; diff --git a/packages/vitnode/src/content/types.ts b/packages/vitnode/src/content/types.ts index 389aa59f55..501aa1748c 100644 --- a/packages/vitnode/src/content/types.ts +++ b/packages/vitnode/src/content/types.ts @@ -18,6 +18,7 @@ import type { CONTENT_TRANSLATION_SYSTEM_FIELDS, } from "./const"; import type { ContentFileDescriptor } from "./files"; +import type { ContentIdStrategy } from "./ids"; import type { RichTextDocument } from "./rich-text/document"; import type { ContentSchemas } from "./schemas"; @@ -930,6 +931,54 @@ type ContentPublicationColumns = TDefinition extends { ? { publishedAt: Date | null; status: ContentPublicationStatus } : Record; +// --------------------------------------------------------------------------- +// Visibility +// --------------------------------------------------------------------------- + +export interface ResolvedContentVisibilityConfig< + TEnabled extends boolean = boolean, +> { + enabled: TEnabled; +} + +/** + * The two generated columns, present only when visibility is enabled. A record + * is hidden exactly when `hiddenAt` is set; there is no separate flag to drift. + */ +type ContentVisibilityColumns = TDefinition extends { + visibility: { enabled: true }; +} + ? { hiddenAt: Date | null; hiddenBy: null | number } + : Record; + +// --------------------------------------------------------------------------- +// Duplication +// --------------------------------------------------------------------------- + +/** The options of `duplication` - `true` is the same as `{}`. */ +export interface ContentDuplicationConfig { + /** + * Appends a localized "(Copy)" suffix to the copy's `admin.titleField`, in + * every copied language, trimmed to the field's `maxLength`. Defaults to + * `true`; it applies only when the title field is a `text` field. + */ + titleSuffix?: boolean; +} + +export interface ResolvedContentDuplicationConfig< + TEnabled extends boolean = boolean, +> { + enabled: TEnabled; + /** The `text` field the copy suffix is appended to, or `null` for none. */ + titleSuffixField: null | string; +} + +/** `duplication`: `true`, an options object, or `false` / omitted for off. */ +export type ContentDuplicationInput = boolean | ContentDuplicationConfig; + +export type ContentDuplicationEnabled = + ContentOptionEnabled; + // --------------------------------------------------------------------------- // Public API // --------------------------------------------------------------------------- @@ -1616,6 +1665,17 @@ export type LocalizedContentTypeDefinition = AnyContentTypeDefinition & { localization: { enabled: true }; }; +/** A content type whose records can be hidden from the public site. */ +export type HideableContentTypeDefinition = AnyContentTypeDefinition & { + publication: { enabled: true }; + visibility: { enabled: true }; +}; + +/** A content type whose records can be duplicated as drafts. */ +export type DuplicableContentTypeDefinition = AnyContentTypeDefinition & { + duplication: { enabled: true }; +}; + export interface ResolvedContentAdvancedConfig { /** One generated junction table per to-many relation field. */ junctions: ContentRelationJunction[]; @@ -1652,12 +1712,17 @@ export interface ContentTypeDefinition< TLocalizationEnabled extends boolean = boolean, TDeliveryEnabled extends boolean = boolean, TPublicPath extends string = string, + TVisibilityEnabled extends boolean = boolean, + TDuplicationEnabled extends boolean = boolean, + TIdStrategy extends ContentIdStrategy = ContentIdStrategy, > { admin: ResolvedContentAdminConfig; /** Generated junction tables, child tables and the leaf-path mapping. */ advanced: ResolvedContentAdvancedConfig; delivery: ResolvedContentDeliveryConfig; + /** Draft copies of a record, or the disabled default when omitted. */ + duplication: ResolvedContentDuplicationConfig; /** Editorial workflow, or the disabled default when `editorial` is omitted. */ editorial: ResolvedContentEditorialConfig< TEditorialEnabled, @@ -1666,6 +1731,8 @@ export interface ContentTypeDefinition< >; fields: TFields; id: TId; + /** How the primary key is generated. `serial` unless the definition says otherwise. */ + idStrategy: TIdStrategy; /** Declared indexes plus the automatic ones, deduplicated and named. */ indexes: ResolvedContentIndex[]; liveEditing: { enabled: boolean }; @@ -1695,12 +1762,18 @@ export interface ContentTypeDefinition< TPreviewEnabled, TSchedulingEnabled, TLocalizationEnabled, - TDeliveryEnabled + TDeliveryEnabled, + TPublicPath, + TVisibilityEnabled, + TDuplicationEnabled, + TIdStrategy > >; /** Search synchronization, or the disabled default when `search` is omitted. */ search: ResolvedContentSearchConfig; tableName: string; + /** Record-level hiding, or the disabled default when `visibility` is omitted. */ + visibility: ResolvedContentVisibilityConfig; } /** Use in constraints where the concrete field map does not matter. */ @@ -1714,7 +1787,8 @@ export type ContentFieldsOf = TDefinition extends { export type ContentSelect = Prettify< ContentEditorialColumns & - ContentPublicationColumns & { + ContentPublicationColumns & + ContentVisibilityColumns & { [K in ColumnFieldKeys>]: ContentFieldValue< ContentFieldsOf[K] >; diff --git a/packages/vitnode/src/locales/en.json b/packages/vitnode/src/locales/en.json index e649d44c39..884d4ed528 100644 --- a/packages/vitnode/src/locales/en.json +++ b/packages/vitnode/src/locales/en.json @@ -1608,6 +1608,7 @@ "can_edit": "Edit", "can_delete": "Delete", "can_publish": "Publish and unpublish", + "can_hide": "Hide and unhide", "can_restore": "Restore an earlier version" }, "errors": { From 1f317023507d761be3b9f4f11689cb63dd2e502a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 07:59:23 +0000 Subject: [PATCH 02/17] =?UTF-8?q?feat(content):=20=E2=9C=A8=20duplicate=20?= =?UTF-8?q?content=20records=20as=20drafts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `duplicate(sourceId, options)` to the plain and editorial services of a content type with `duplication: { enabled: true }`, orchestrated once in `content/server/duplicate.ts` on top of the existing create pipeline: one transaction, the source locked FOR SHARE, every collection and translation copied as a draft with its own create revision, fresh slugs per locale (live rows and redirect history, bounded candidates, savepoint retry on a concurrent 23505), a localized title suffix and a 422 for unique fields that need an override. `POST /{id}/duplicate` (can_create + can_view) runs the standard create effects once after commit, then the `duplicated` event. Includes a typed AdminCP client helper and Postgres-backed tests. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VpG3D33ZqXEYjN2N6eNcMK --- packages/vitnode/src/content/conflicts.ts | 40 + packages/vitnode/src/content/const.ts | 24 + packages/vitnode/src/content/errors.ts | 61 ++ packages/vitnode/src/content/index.ts | 6 + .../src/content/server/duplicate-effects.ts | 134 +++ .../src/content/server/duplicate.test-d.ts | 70 ++ .../src/content/server/duplicate.test.ts | 30 + .../vitnode/src/content/server/duplicate.ts | 820 ++++++++++++++++++ .../src/content/server/editorial-effects.ts | 6 + .../src/content/server/editorial-service.ts | 96 +- .../vitnode/src/content/server/http-errors.ts | 31 + packages/vitnode/src/content/server/index.ts | 24 + packages/vitnode/src/content/server/model.ts | 30 +- packages/vitnode/src/content/server/routes.ts | 171 ++++ .../vitnode/src/content/server/service.ts | 68 ++ packages/vitnode/src/lib/api/pg-error.ts | 18 + packages/vitnode/src/locales/api/en.json | 5 + .../src/tanstack/admin/content/query.ts | 22 + .../vitnode/src/tests/content-fixtures.ts | 84 ++ .../admin/views/content/content-mutation.ts | 3 + .../admin/views/content/lib/api-result.ts | 2 + .../views/content/table/list-mutations.ts | 63 ++ 22 files changed, 1802 insertions(+), 6 deletions(-) create mode 100644 packages/vitnode/src/content/server/duplicate-effects.ts create mode 100644 packages/vitnode/src/content/server/duplicate.test-d.ts create mode 100644 packages/vitnode/src/content/server/duplicate.test.ts create mode 100644 packages/vitnode/src/content/server/duplicate.ts diff --git a/packages/vitnode/src/content/conflicts.ts b/packages/vitnode/src/content/conflicts.ts index 67fdb44ea1..f99c75bea4 100644 --- a/packages/vitnode/src/content/conflicts.ts +++ b/packages/vitnode/src/content/conflicts.ts @@ -3,6 +3,7 @@ import { z } from "zod"; import { CONTENT_CONFLICT_CODES, CONTENT_DELIVERY_CODES, + CONTENT_DUPLICATE_CODES, CONTENT_SCHEDULE_CODES, CONTENT_TRANSLATION_CONFLICT_CODES, CONTENT_UNPROCESSABLE_CODES, @@ -182,3 +183,42 @@ export const parseContentUnprocessable = ( return null; } }; + +/** + * Why a duplicate was refused, when it was refused for a reason a form can act + * on: a slug with no free candidate (409, names the field and the locale) or a + * unique field that needs an override (422, names every such field). + */ +export const zodContentDuplicateRejection = z.discriminatedUnion("code", [ + z.object({ + code: z.literal(CONTENT_DUPLICATE_CODES.slugConflict), + contentTypeId: z.string(), + field: z.string(), + locale: z.string().nullable(), + slug: z.string(), + }), + z.object({ + code: z.literal(CONTENT_DUPLICATE_CODES.uniqueRequired), + contentTypeId: z.string(), + fields: z.array(z.string()), + }), +]); + +export type ContentDuplicateRejection = z.infer< + typeof zodContentDuplicateRejection +>; + +/** Reads a duplicate rejection out of a response body, or `null`. */ +export const parseContentDuplicateRejection = ( + body: string | undefined, +): ContentDuplicateRejection | null => { + if (body === undefined || body === "") return null; + + try { + const parsed = zodContentDuplicateRejection.safeParse(JSON.parse(body)); + + return parsed.success ? parsed.data : null; + } catch { + return null; + } +}; diff --git a/packages/vitnode/src/content/const.ts b/packages/vitnode/src/content/const.ts index d6d0840e59..ad8dcd1c60 100644 --- a/packages/vitnode/src/content/const.ts +++ b/packages/vitnode/src/content/const.ts @@ -415,3 +415,27 @@ export const CONTENT_TRANSLATION_CONFLICT_CODES = { export const CONTENT_UNPROCESSABLE_CODES = { notRestorable: "CONTENT_REVISION_NOT_RESTORABLE", } as const; + +/** Machine-readable reasons a duplicate was refused. */ +export const CONTENT_DUPLICATE_CODES = { + /** Every slug candidate was taken, or kept being taken by concurrent writers. 409. */ + slugConflict: "CONTENT_DUPLICATE_SLUG_CONFLICT", + /** A unique field would collide with the source and no override was given. 422. */ + uniqueRequired: "CONTENT_DUPLICATE_UNIQUE_REQUIRED", +} as const; + +/** How many slug candidates a duplicate considers: `hello-copy`, `hello-copy-2`, ... */ +export const CONTENT_DUPLICATE_SLUG_CANDIDATES = 20; + +/** How many writes a duplicate attempts when concurrent writers keep taking its slug. */ +export const CONTENT_DUPLICATE_SLUG_ATTEMPTS = 3; + +/** Appended to a copied slug that has no source field to derive a fresh one from. */ +export const CONTENT_DUPLICATE_SLUG_SUFFIX = "copy"; + +/** The title suffix when no translated message is available. */ +export const CONTENT_DUPLICATE_TITLE_SUFFIX = "(Copy)"; + +/** The server message holding the localized title suffix. */ +export const CONTENT_DUPLICATE_TITLE_SUFFIX_KEY = + "core.content.duplicate.title_suffix"; diff --git a/packages/vitnode/src/content/errors.ts b/packages/vitnode/src/content/errors.ts index a5667da8b2..d63a997db9 100644 --- a/packages/vitnode/src/content/errors.ts +++ b/packages/vitnode/src/content/errors.ts @@ -374,3 +374,64 @@ export class ContentScheduleError extends ContentEngineError { readonly code: ContentScheduleCode; } + +/** + * A duplicate could not find a free slug: every candidate is taken, or + * concurrent copies kept taking the one it picked. + * + * Per-request and structured, so the AdminCP can point at the slug field and + * ask for an override rather than show a bare 409. + */ +export class ContentDuplicateSlugConflict extends ContentEngineError { + constructor({ + contentTypeId, + field, + locale, + slug, + }: { + contentTypeId: string; + field: string; + locale: null | string; + slug: string; + }) { + super( + locale === null + ? `Could not find a free "${field}" for the copy (last tried "${slug}"). Send "${field}" explicitly.` + : `Could not find a free "${field}" in "${locale}" for the copy (last tried "${slug}"). Send "${field}" explicitly.`, + { contentTypeId }, + ); + + this.name = "ContentDuplicateSlugConflict"; + this.field = field; + this.locale = locale; + this.slug = slug; + } + + readonly field: string; + readonly locale: null | string; + readonly slug: string; +} + +/** + * A duplicate would copy a unique value onto a second record. Thrown before + * anything is written; `fields` are canonical paths (`code`, `seo.handle`). + */ +export class ContentDuplicateUniqueRequired extends ContentEngineError { + constructor({ + contentTypeId, + fields, + }: { + contentTypeId: string; + fields: string[]; + }) { + super( + `A copy needs a new value for ${fields.map(name => `"${name}"`).join(", ")}: ${fields.length === 1 ? "it is" : "they are"} unique, and the source already holds the current one.`, + { contentTypeId }, + ); + + this.name = "ContentDuplicateUniqueRequired"; + this.fields = fields; + } + + readonly fields: string[]; +} diff --git a/packages/vitnode/src/content/index.ts b/packages/vitnode/src/content/index.ts index b611b9f1d6..e091723047 100644 --- a/packages/vitnode/src/content/index.ts +++ b/packages/vitnode/src/content/index.ts @@ -76,10 +76,12 @@ export type { export { parseContentConflict, parseContentDeliveryConflict, + parseContentDuplicateRejection, parseContentTranslationConflict, parseContentUnprocessable, zodContentConflict, zodContentDeliveryConflict, + zodContentDuplicateRejection, zodContentTranslationConflict, zodContentUnprocessable, } from "./conflicts"; @@ -88,6 +90,7 @@ export type { ContentConflictCode, ContentDeliveryCode, ContentDeliveryConflict, + ContentDuplicateRejection, ContentTranslationConflict, ContentTranslationConflictCode, ContentUnprocessable, @@ -107,6 +110,7 @@ export { CONTENT_DELIVERY_REDIRECT_STATUS, CONTENT_DELIVERY_RESOLUTIONS, CONTENT_DELIVERY_TITLE_KINDS, + CONTENT_DUPLICATE_CODES, CONTENT_EDITORIAL_FIELDS, CONTENT_ENUM_DEFAULT_LENGTH, CONTENT_FILE_CODES, @@ -200,6 +204,8 @@ export { ContentDefaultTranslationRequired, ContentDeliveryNotEnabled, ContentDeliverySlugReserved, + ContentDuplicateSlugConflict, + ContentDuplicateUniqueRequired, ContentEngineError, ContentInputError, ContentLanguageError, diff --git a/packages/vitnode/src/content/server/duplicate-effects.ts b/packages/vitnode/src/content/server/duplicate-effects.ts new file mode 100644 index 0000000000..949de7b109 --- /dev/null +++ b/packages/vitnode/src/content/server/duplicate-effects.ts @@ -0,0 +1,134 @@ +import type { Context } from "hono"; + +import type { EventEmitResult } from "../../api/models/events"; +import type { AnyContentTypeDefinition } from "../types"; +import type { + ContentDuplicateResult, + ContentEditorialDuplicateOutcome, +} from "./duplicate"; +import type { AnyContentModel } from "./model"; + +import { contentEditorialEffects } from "./editorial-effects"; +import { reportContentEventFailures } from "./effects-log"; +import { emitContentEvent } from "./emit"; +import { plainOutcome } from "./route-helpers"; +import { + contentSearchAdvancedValues, + syncContentLocalizedSearch, + syncContentSearch, +} from "./search-sync"; +import { contentTranslationEffects } from "./translation-effects"; + +export type ContentDuplicateEffectsInput = + | { + kind: "editorial"; + outcome: ContentEditorialDuplicateOutcome; + } + | { + kind: "plain"; + result: ContentDuplicateResult; + }; + +export interface ContentDuplicateEffectsOptions { + /** Who asked for the copy; `null` for the system or an API key. */ + actorUserId: null | number; + /** The plugin that owns the content type, and therefore the events. */ + pluginId: string; +} + +/** + * Everything a committed duplicate owes the rest of the system, once. + * + * The copy's ordinary `created` (carrying `duplicatedFromId`) with its search + * document, one `translation_created` per copied language, then `duplicated`. + * Search runs exactly once per document: a localized record's base effects + * already index every language, so the translation effects are run without the + * model and only announce. + */ +export const contentDuplicateEffects = async ( + c: Context, + model: AnyContentModel, + input: ContentDuplicateEffectsInput, + { actorUserId, pluginId }: ContentDuplicateEffectsOptions, +): Promise<{ duplicated: EventEmitResult }> => { + const definition = model.definition as AnyContentTypeDefinition; + const row = input.kind === "editorial" ? input.outcome.row : input.result.row; + const sourceId = + input.kind === "editorial" ? input.outcome.sourceId : input.result.sourceId; + const contentId = (row as { id: number }).id; + + if (input.kind === "editorial") { + await contentEditorialEffects(c, definition, input.outcome, { + model, + pluginId, + }); + + for (const translation of input.outcome.translations) { + await contentTranslationEffects(c, definition, translation, { + pluginId, + }); + } + } else { + const created = await emitContentEvent( + c, + definition, + "created", + { contentId, duplicatedFromId: sourceId }, + { pluginId }, + ); + await reportContentEventFailures(c, { + action: "created", + contentTypeId: definition.id, + event: created, + itemId: contentId, + }); + + const advanced = await contentSearchAdvancedValues(c, model, contentId); + if (definition.localization.enabled) { + await syncContentLocalizedSearch(c, model, { + advanced, + changed: true, + operation: "create", + pluginId, + row, + }); + } else { + await syncContentSearch(c, definition, { + advanced, + operation: "create", + pluginId, + row, + }); + } + + for (const translation of input.result.translations) { + await contentTranslationEffects( + c, + definition, + plainOutcome("create", translation), + { pluginId }, + ); + } + } + + const duplicated = await emitContentEvent( + c, + definition, + "duplicated", + { + actorUserId, + contentId, + contentTypeId: definition.id, + sourceId, + }, + { pluginId }, + ); + await reportContentEventFailures(c, { + action: "duplicated", + contentTypeId: definition.id, + event: duplicated, + itemId: contentId, + }); + + return { duplicated }; +}; diff --git a/packages/vitnode/src/content/server/duplicate.test-d.ts b/packages/vitnode/src/content/server/duplicate.test-d.ts new file mode 100644 index 0000000000..4c06d89e31 --- /dev/null +++ b/packages/vitnode/src/content/server/duplicate.test-d.ts @@ -0,0 +1,70 @@ +import type { Context } from "hono"; + +import { describe, expectTypeOf, it } from "vitest"; + +import { + testCategoryContentType, + testDuplicableArticleContentType, + testDuplicableNoteContentType, +} from "@/tests/content-fixtures"; + +import type { ContentEventsFor } from "../events"; +import type { + ContentDuplicateResult, + ContentEditorialDuplicateOutcome, +} from "./duplicate"; + +import { createContentModel } from "./model"; + +const categories = createContentModel(testCategoryContentType); +const articles = createContentModel(testDuplicableArticleContentType, { + references: { categories: () => categories.table.id }, +}); +const notes = createContentModel(testDuplicableNoteContentType); + +declare const c: Context; + +type Note = typeof testDuplicableNoteContentType; +type Article = typeof testDuplicableArticleContentType; + +describe("duplicate", () => { + it("exists only on a content type that enables duplication", () => { + expectTypeOf(notes.service(c).duplicate).toBeFunction(); + expectTypeOf(categories.service(c).duplicate).toEqualTypeOf(); + }); + + it("takes and returns the content type's own identifier", () => { + expectTypeOf(notes.service(c).duplicate) + .parameter(0) + .toEqualTypeOf(); + expectTypeOf( + notes.service(c).duplicate, + ).returns.resolves.toEqualTypeOf | null>(); + }); + + it("is the editorial outcome on the editorial service, actor required", () => { + const editorial = articles.editorialService?.(c, { pluginId: "x" }); + if (!editorial) return; + + expectTypeOf( + editorial.duplicate, + ).returns.resolves.toEqualTypeOf | null>(); + expectTypeOf(editorial.duplicate).parameter(1).toHaveProperty("actor"); + }); + + it("types overrides from the content type's own fields", () => { + // @ts-expect-error - `nope` is not a field of the note content type + void notes.service(c).duplicate(1, { overrides: { nope: true } }); + }); + + it("announces the copy with a typed `duplicated` event", () => { + expectTypeOf< + ContentEventsFor["content.test.duplicable-note.duplicated"] + >().toEqualTypeOf<{ + actorUserId: null | number; + contentId: number; + contentTypeId: string; + sourceId: number; + }>(); + }); +}); diff --git a/packages/vitnode/src/content/server/duplicate.test.ts b/packages/vitnode/src/content/server/duplicate.test.ts new file mode 100644 index 0000000000..bda2138566 --- /dev/null +++ b/packages/vitnode/src/content/server/duplicate.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; + +import { appendContentTitleSuffix, contentSlugCandidate } from "./duplicate"; + +describe("contentSlugCandidate", () => { + it("keeps the numeric tail when the base already fills the column", () => { + expect(contentSlugCandidate("abcdefghij", 1, 10)).toBe("abcdefghij"); + expect(contentSlugCandidate("abcdefghij", 2, 10)).toBe("abcdefgh-2"); + expect(contentSlugCandidate("abcdefghij", 12, 10)).toBe("abcdefg-12"); + }); + + it("never leaves a dash in front of the tail", () => { + expect(contentSlugCandidate("abcdefg-ij", 2, 10)).toBe("abcdefg-2"); + }); +}); + +describe("appendContentTitleSuffix", () => { + it("leaves a title that fits untouched", () => { + expect(appendContentTitleSuffix("Hello", "(Copy)", 20)).toBe( + "Hello (Copy)", + ); + }); + + it("trims the title, never the suffix, and never half a character", () => { + // 🎉 is two UTF-16 units: cutting between them would store a broken glyph. + expect(appendContentTitleSuffix("Party 🎉🎉 time", "(Copy)", 15)).toBe( + "Party 🎉 (Copy)", + ); + }); +}); diff --git a/packages/vitnode/src/content/server/duplicate.ts b/packages/vitnode/src/content/server/duplicate.ts new file mode 100644 index 0000000000..992d1bf9ae --- /dev/null +++ b/packages/vitnode/src/content/server/duplicate.ts @@ -0,0 +1,820 @@ +import type { PgColumn, PgTable } from "drizzle-orm/pg-core"; +import type { Context } from "hono"; + +import { and, eq, inArray } from "drizzle-orm"; + +import type { ContentId, ContentIdOf } from "../ids"; +import type { ContentActor } from "../revisions"; +import type { + AnyContentTypeDefinition, + ContentFieldDescriptor, + ContentFieldMap, + ContentLocalizedUpdateValues, + ContentSelect, + ContentTranslationRow, + ContentUpdateInput, +} from "../types"; +import type { ContentAdvancedStore } from "./advanced-store"; +import type { ContentEditorialOutcome } from "./editorial-service"; +import type { ContentLanguage } from "./language-resolver"; +import type { ContentDatabase } from "./service"; +import type { ContentTranslationEditorialOutcome } from "./translation-editorial-service"; + +import { + PG_ERROR_CODES, + pgConstraintName, + pgErrorCode, +} from "../../lib/api/pg-error"; +import { + CONTENT_DUPLICATE_SLUG_ATTEMPTS, + CONTENT_DUPLICATE_SLUG_CANDIDATES, + CONTENT_DUPLICATE_SLUG_SUFFIX, + CONTENT_DUPLICATE_TITLE_SUFFIX, + CONTENT_DUPLICATE_TITLE_SUFFIX_KEY, + CONTENT_SLUG_DEFAULT_LENGTH, +} from "../const"; +import { + ContentDuplicateSlugConflict, + ContentDuplicateUniqueRequired, + ContentEngineError, + ContentInputError, +} from "../errors"; +import { partitionContentFields } from "../localization"; +import { + contentColumnsToValues, + contentInnerFields, + contentStorageColumns, + contentValuesToColumns, +} from "../paths"; +import { slugify } from "../slug"; +import { contentSlugHistoryFor } from "./delivery-writes"; + +export interface ContentDuplicateOptions { + /** + * Shared values the copy takes instead of the source's. A group is merged leaf + * by leaf; any other field replaces the copied value. Validated with the rest + * of the copy, exactly like a create. + */ + overrides?: ContentUpdateInput; + /** + * Localized values per copied locale, merged the same way. A locale the source + * has no translation in is refused. + */ + translations?: Partial< + Record> + >; + /** Join an existing transaction instead of opening one. */ + tx?: ContentDatabase; +} + +export interface ContentEditorialDuplicateOptions< + TDefinition, +> extends ContentDuplicateOptions { + actor: ContentActor; +} + +export interface ContentDuplicateResult { + /** The copy: a new draft. */ + row: ContentSelect; + /** Source locales the install has switched off, so the copy does not carry them. */ + skippedLocales: string[]; + sourceId: ContentIdOf; + /** The copied translations, default locale first. Empty when not localized. */ + translations: ContentTranslationRow[]; +} + +export interface ContentEditorialDuplicateOutcome< + TDefinition, +> extends ContentEditorialOutcome { + duplicatedFromId: ContentIdOf; + skippedLocales: string[]; + sourceId: ContentIdOf; + /** One `create` outcome per copied translation, default locale first. */ + translations: ContentTranslationEditorialOutcome[]; +} + +/** `duplicate`, present on a content type with `duplication: { enabled: true }`. */ +export interface ContentDuplicationMethods { + /** + * Copies one record as a new draft, with its collections and every translation. + * One transaction; `null` when the source does not exist. Emits nothing - the + * caller runs `contentDuplicateEffects` after the commit. + */ + duplicate: ( + sourceId: ContentIdOf, + options?: ContentDuplicateOptions, + ) => Promise | null>; +} + +export interface ContentEditorialDuplicationMethods { + /** The editorial twin: the copy and each translation get their `create` revision. */ + duplicate: ( + sourceId: ContentIdOf, + options: ContentEditorialDuplicateOptions, + ) => Promise | null>; +} + +/** The part of the translation model a duplicate reads through. */ +export interface ContentDuplicateTranslationSource { + findManyRowsForItem: ( + itemId: number, + options?: { tx?: ContentDatabase }, + ) => Promise<{ languageId: number; locale: string; values: object }[]>; + resolveLanguage: ( + locale: string, + options?: { requireEnabled?: boolean; tx?: ContentDatabase }, + ) => Promise; +} + +export interface ContentDuplicateDependencies { + advanced?: ContentAdvancedStore; + c: Context; + columns: Record; + definition: AnyContentTypeDefinition; + /** Only names the slug history model; a duplicate never writes history itself. */ + pluginId?: string; + table: PgTable; + translation?: { + columns: Record; + model: () => ContentDuplicateTranslationSource; + table: PgTable; + }; +} + +/** + * The write half of a duplicate - the ordinary create pipeline, plain or + * editorial - always called with the duplicate's own transaction. + */ +export interface ContentDuplicateWriter { + base: ( + values: Record, + tx: ContentDatabase, + ) => Promise; + idOf: (base: TBase) => number; + translation: ( + itemId: number, + locale: string, + values: Record, + tx: ContentDatabase, + ) => Promise; +} + +interface SlugPlan { + base: string; + field: string; + /** Whether this slug is the delivery address, and so checked against history. */ + history: boolean; + maxLength: number; +} + +interface SlugScope { + languageId: null | number; + locale: null | string; +} + +type Values = Record; + +const isRecord = (value: unknown): value is Values => + typeof value === "object" && value !== null && !Array.isArray(value); + +const pickLeaves = (inner: ContentFieldMap, value: Values): Values => + Object.fromEntries( + Object.entries(inner) + .filter(([leaf]) => value[leaf] !== undefined) + .map(([leaf, leafValue]) => [leaf, toInput(leafValue, value[leaf])]), + ); + +/** + * One stored value in the shape a create accepts: a timestamp as ISO text, a + * group by its declared leaves, a repeatable child **without** its id - a copy + * owns new children, and an id would point at the source's. + */ +const toInput = ( + fieldValue: ContentFieldDescriptor, + value: unknown, +): unknown => { + if (value === null || value === undefined) return value; + + switch (fieldValue.kind) { + case "dateTime": + return value instanceof Date ? value.toISOString() : value; + case "group": + return isRecord(value) + ? pickLeaves(contentInnerFields(fieldValue), value) + : value; + case "repeatable": + return Array.isArray(value) + ? value.map(row => + isRecord(row) + ? pickLeaves(contentInnerFields(fieldValue), row) + : row, + ) + : value; + default: + return Array.isArray(value) ? [...(value as unknown[])] : value; + } +}; + +/** Built from the declared writable fields only, so nothing generated is copied. */ +const copyValues = (fields: ContentFieldMap, values: Values): Values => + Object.fromEntries( + Object.entries(fields) + .filter(([name]) => values[name] !== undefined) + .map(([name, fieldValue]) => [name, toInput(fieldValue, values[name])]), + ); + +const applyOverrides = ( + fields: ContentFieldMap, + copied: Values, + overrides: Values, +): Values => { + const next = { ...copied }; + + for (const [name, value] of Object.entries(overrides)) { + if (value === undefined) continue; + const fieldValue = fields[name] as ContentFieldDescriptor | undefined; + + if (fieldValue?.kind === "group" && isRecord(value)) { + const current = next[name]; + next[name] = { ...(isRecord(current) ? current : {}), ...value }; + continue; + } + + // A child id in an override would name one of the source's children. + if (fieldValue?.kind === "repeatable" && Array.isArray(value)) { + next[name] = value.map(row => + isRecord(row) + ? Object.fromEntries( + Object.entries(row).filter(([key]) => key !== "id"), + ) + : row, + ); + continue; + } + + // Unknown keys are kept, so the strict create schema refuses them. + next[name] = value; + } + + return next; +}; + +/** + * Appends the suffix, trimming the title so the whole fits `maxLength`. Measured + * in UTF-16 units like the schema, and never splitting a surrogate pair. + */ +export const appendContentTitleSuffix = ( + title: string, + suffix: string, + maxLength?: number, +): string => { + const tail = ` ${suffix}`; + if (maxLength === undefined || title.length + tail.length <= maxLength) { + return `${title}${tail}`; + } + + const budget = maxLength - tail.length; + if (budget <= 0) return `${title}${tail}`.slice(0, maxLength); + + let kept = ""; + for (const point of title) { + if (kept.length + point.length > budget) break; + kept += point; + } + + return `${kept.trimEnd()}${tail}`; +}; + +/** + * The n-th slug candidate: `hello-copy`, `hello-copy-2`, ... - trimmed so the + * numeric tail always survives `maxLength`. + */ +export const contentSlugCandidate = ( + base: string, + position: number, + maxLength: number, +): string => { + if (position <= 1) return base.slice(0, maxLength).replace(/-+$/, ""); + + const tail = `-${position}`; + const head = base + .slice(0, Math.max(maxLength - tail.length, 0)) + .replace(/-+$/, ""); + + return head === "" ? tail.slice(1) : `${head}${tail}`; +}; + +const sameValue = (left: unknown, right: unknown): boolean => { + if (left instanceof Date || right instanceof Date) { + return ( + new Date(left as Date | string).getTime() === + new Date(right as Date | string).getTime() + ); + } + + return left === right; +}; + +/** The orchestration both services share. Writes happen through `writer` only. */ +export const runContentDuplicate = async ( + { + advanced, + c, + columns, + definition, + pluginId, + table, + translation, + }: ContentDuplicateDependencies, + sourceId: ContentId, + options: { + overrides?: object; + translations?: Partial>; + tx?: ContentDatabase; + }, + writer: ContentDuplicateWriter, +): Promise => { + const contentTypeId = definition.id; + + if (!definition.duplication.enabled) { + throw new ContentEngineError( + "duplicate needs `duplication: { enabled: true }` on the content type.", + { contentTypeId }, + ); + } + + const localized = definition.localization.enabled; + if (localized && !translation) { + throw new ContentEngineError( + "A localized content type duplicates its translations too, so the service needs the translation model. Use `model.service(c)` rather than building the service by hand.", + { contentTypeId }, + ); + } + + // Serial is the only strategy with tables today; `parseContentId` is the + // boundary that will widen this. + const id = Number(sourceId); + const { localizedFields, sharedFields, collectionFields } = + partitionContentFields(definition.fields); + const writableFields: ContentFieldMap = { + ...sharedFields, + ...collectionFields, + }; + const storage = contentStorageColumns(sharedFields); + const titleField = definition.duplication.titleSuffixField; + const deliverySlugField = definition.publicApi.enabled + ? definition.publicApi.slugField + : null; + const slugHistory = contentSlugHistoryFor({ + c, + definition, + pluginId: pluginId ?? "", + }); + + const overrides = (options.overrides ?? {}) as Values; + const localeOverrides = new Map(); + for (const [locale, values] of Object.entries(options.translations ?? {})) { + if (values !== undefined) { + localeOverrides.set(locale.toLowerCase(), values as Values); + } + } + + const suffixes = new Map>(); + const titleSuffix = async (locale: string | undefined): Promise => { + const key = locale ?? ""; + let pending = suffixes.get(key); + if (!pending) { + pending = resolveContentTitleSuffix(c, locale); + suffixes.set(key, pending); + } + + return await pending; + }; + + const withTitleSuffix = async ( + fields: ContentFieldMap, + values: Values, + overridden: Values, + locale: string | undefined, + ): Promise => { + if (titleField === null || !(titleField in fields)) return values; + if (overridden[titleField] !== undefined) return values; + + const title = values[titleField]; + if (typeof title !== "string" || title.trim() === "") return values; + + const { maxLength } = fields[titleField] as { maxLength?: number }; + + return { + ...values, + [titleField]: appendContentTitleSuffix( + title, + await titleSuffix(locale), + maxLength, + ), + }; + }; + + const slugPlans = ( + fields: ContentFieldMap, + values: Values, + source: Values, + overridden: Values, + ): SlugPlan[] => + Object.entries(fields) + .filter( + ([name, fieldValue]) => + fieldValue.kind === "slug" && overridden[name] === undefined, + ) + .map(([name, fieldValue]) => { + const { maxLength = CONTENT_SLUG_DEFAULT_LENGTH, source: from } = + fieldValue as { maxLength?: number; source?: string }; + const derived = + from !== undefined && typeof values[from] === "string" + ? slugify(values[from], maxLength) + : ""; + const current = typeof source[name] === "string" ? source[name] : ""; + + return { + base: + derived !== "" + ? derived + : slugify( + current === "" + ? CONTENT_DUPLICATE_SLUG_SUFFIX + : `${current}-${CONTENT_DUPLICATE_SLUG_SUFFIX}`, + maxLength, + ), + field: name, + history: slugHistory !== null && name === deliverySlugField, + maxLength, + }; + }); + + /** The first candidate no live row holds and no other record's history owns. */ + const chooseSlug = async ( + tx: ContentDatabase, + plan: SlugPlan, + scope: SlugScope, + excluded: ReadonlySet, + ): Promise => { + const candidates = [ + ...new Set( + Array.from({ length: CONTENT_DUPLICATE_SLUG_CANDIDATES }, (_, index) => + contentSlugCandidate(plan.base, index + 1, plan.maxLength), + ), + ), + ].filter(candidate => candidate !== "" && !excluded.has(candidate)); + + const { languageId } = scope; + const target = + languageId !== null && translation + ? { + column: translation.columns[plan.field], + scope: eq(translation.columns.languageId, languageId), + table: translation.table, + } + : { column: columns[plan.field], scope: undefined, table }; + const rows = + candidates.length > 0 + ? await tx + .select({ slug: target.column }) + .from(target.table) + .where(and(target.scope, inArray(target.column, candidates))) + : []; + const live = new Set(rows.map(row => String(row.slug))); + + for (const candidate of candidates) { + if (live.has(candidate)) continue; + if ( + plan.history && + slugHistory && + (await slugHistory.owner( + { languageId: scope.languageId, slug: candidate }, + tx, + )) !== null + ) { + continue; + } + + return candidate; + } + + throw new ContentDuplicateSlugConflict({ + contentTypeId, + field: plan.field, + locale: scope.locale, + slug: candidates.at(-1) ?? plan.base, + }); + }; + + /** The slug field a unique violation is about, or `undefined` when it is not one we chose. */ + const collidedSlug = ( + error: unknown, + plans: readonly SlugPlan[], + scope: SlugScope, + ): string | undefined => { + if (pgErrorCode(error) !== PG_ERROR_CODES.uniqueViolation) return undefined; + + const constraint = pgConstraintName(error); + // A driver that does not name the constraint: assume the slug, which is the + // only value this write invented. The attempt limit still bounds it. + if (constraint === undefined) return plans[0]?.field; + + const indexes = + scope.languageId === null + ? definition.indexes + : definition.localization.translationIndexes; + + return plans.find(plan => + indexes.some( + index => + index.unique && + index.name === constraint && + index.on.at(-1) === plan.field, + ), + )?.field; + }; + + /** + * One write with fresh slugs, retried in a savepoint when a concurrent copy + * commits the same slug first. The savepoint is what keeps the transaction + * usable after the `23505`; the attempt limit is what keeps it finite. + */ + const writeWithSlugs = async ( + tx: ContentDatabase, + plans: readonly SlugPlan[], + scope: SlugScope, + write: (db: ContentDatabase, slugs: Values) => Promise, + ): Promise => { + const excluded = new Map>(); + + for (let attempt = 1; ; attempt += 1) { + const slugs: Record = {}; + for (const plan of plans) { + slugs[plan.field] = await chooseSlug( + tx, + plan, + scope, + excluded.get(plan.field) ?? new Set(), + ); + } + + if (plans.length === 0) return await write(tx, slugs); + + try { + return await tx.transaction( + async savepoint => await write(savepoint, slugs), + ); + } catch (error) { + const field = collidedSlug(error, plans, scope); + if (field === undefined) throw error; + + if (attempt >= CONTENT_DUPLICATE_SLUG_ATTEMPTS) { + throw new ContentDuplicateSlugConflict({ + contentTypeId, + field, + locale: scope.locale, + slug: slugs[field], + }); + } + + const taken = excluded.get(field) ?? new Set(); + taken.add(slugs[field]); + excluded.set(field, taken); + } + } + }; + + /** + * Every unique index the copy would collide on: all of its columns hold a + * value, and the copy would write the same values again. A slug is never + * among them - it is regenerated. + */ + const missingUniqueOverrides = (raw: Values, copy: Values): string[] => { + const regenerated = new Set( + Object.entries(sharedFields) + .filter( + ([name, fieldValue]) => + fieldValue.kind === "slug" && overrides[name] === undefined, + ) + .map(([name]) => name), + ); + const written = contentValuesToColumns(sharedFields, copy); + const pathOf = new Map( + definition.advanced.leaves.map(leaf => [leaf.columnName, leaf.path]), + ); + const fields = new Set(); + + for (const index of definition.indexes) { + if (!index.unique) continue; + if (index.on.some(column => regenerated.has(column))) continue; + if (!index.on.every(column => column in storage)) continue; + if (index.on.some(column => raw[column] === null)) continue; + if (!index.on.every(column => sameValue(written[column], raw[column]))) { + continue; + } + + for (const column of index.on) fields.add(pathOf.get(column) ?? column); + } + + return [...fields]; + }; + + const transact = async ( + body: (tx: ContentDatabase) => Promise, + ): Promise => + options.tx + ? await body(options.tx) + : await c.get("db").transaction(async tx => await body(tx)); + + return await transact(async tx => { + // Locked `FOR SHARE` and read in the same statement: concurrent copies of one + // source run side by side, while an edit of it waits until this commits - so + // the base row, its collections and its translations are one state. + const [raw] = await tx + .select({ + id: columns.id, + ...Object.fromEntries( + Object.keys(storage).map(name => [name, columns[name]]), + ), + }) + .from(table) + .where(eq(columns.id, id)) + .limit(1) + .for("share"); + if (!raw) return null; + + // A translation edit never touches the base row, so its rows are locked too. + if (translation) { + await tx + .select({ itemId: translation.columns.itemId }) + .from(translation.table) + .where(eq(translation.columns.itemId, id)) + .for("share"); + } + + const source: Values = { + ...contentColumnsToValues(sharedFields, raw), + ...(advanced?.enabled ? await advanced.load(id, tx) : {}), + }; + const sourceTranslations = translation + ? await translation.model().findManyRowsForItem(id, { tx }) + : []; + + // Refused before anything is written, like the unique check below. + const sourceLocales = new Set( + sourceTranslations.map(row => row.locale.toLowerCase()), + ); + const unknownLocale = [...localeOverrides.keys()].find( + locale => !sourceLocales.has(locale), + ); + if (unknownLocale !== undefined) { + throw new ContentInputError( + `The source has no "${unknownLocale}" translation, so there is nothing to override in that language.`, + { contentTypeId }, + ); + } + + const copied = await withTitleSuffix( + sharedFields, + applyOverrides( + writableFields, + copyValues(writableFields, source), + overrides, + ), + overrides, + localized ? definition.localization.defaultLocale : undefined, + ); + + const unique = missingUniqueOverrides(raw, copied); + if (unique.length > 0) { + throw new ContentDuplicateUniqueRequired({ + contentTypeId, + fields: unique, + }); + } + + const base = await writeWithSlugs( + tx, + slugPlans(sharedFields, copied, source, overrides), + { languageId: null, locale: null }, + async (db, slugs) => await writer.base({ ...copied, ...slugs }, db), + ); + const itemId = writer.idOf(base); + + // The default language first, as the composite create writes it: the copy is + // never a record without it, even inside this transaction. + const isDefault = (locale: string): boolean => + locale.toLowerCase() === + definition.localization.defaultLocale.toLowerCase(); + const ordered = [...sourceTranslations].sort((left, right) => + isDefault(left.locale) ? -1 : isDefault(right.locale) ? 1 : 0, + ); + + const translations: TTranslation[] = []; + const skippedLocales: string[] = []; + + for (const row of ordered) { + const language = await translation?.model().resolveLanguage(row.locale, { + tx, + }); + // A language switched off accepts no new content, so the copy cannot carry + // it. Reported rather than failing the whole copy over one locale. + if (language?.isEnabled === false) { + skippedLocales.push(row.locale); + continue; + } + + const sourceValues = row.values as Values; + const localeOverride = + localeOverrides.get(row.locale.toLowerCase()) ?? {}; + const values = await withTitleSuffix( + localizedFields, + applyOverrides( + localizedFields, + copyValues(localizedFields, sourceValues), + localeOverride, + ), + localeOverride, + row.locale, + ); + + translations.push( + await writeWithSlugs( + tx, + slugPlans(localizedFields, values, sourceValues, localeOverride), + { languageId: row.languageId, locale: row.locale }, + async (db, slugs) => + await writer.translation( + itemId, + row.locale, + { ...values, ...slugs }, + db, + ), + ), + ); + } + + return { base, skippedLocales, translations }; + }); +}; + +/** + * The localized " (Copy)" a copied title ends with, from the server messages + * (`core.content.duplicate.title_suffix`), falling back to the install's default + * locale and then to English when no translator is available. + */ +export const resolveContentTitleSuffix = async ( + c: Context, + locale: string | undefined, +): Promise => { + const i18n = c.get("i18n") as + | undefined + | { + getTranslator: ( + locale?: string, + ) => Promise< + ((key: string) => string) & { has?: (key: string) => boolean } + >; + resolveSupportedLocale: (preferred?: string) => string; + }; + if (!i18n) return CONTENT_DUPLICATE_TITLE_SUFFIX; + + try { + const t = await i18n.getTranslator(i18n.resolveSupportedLocale(locale)); + if (t.has && !t.has(CONTENT_DUPLICATE_TITLE_SUFFIX_KEY)) { + return CONTENT_DUPLICATE_TITLE_SUFFIX; + } + + const value = t(CONTENT_DUPLICATE_TITLE_SUFFIX_KEY).trim(); + + return value === "" || value === CONTENT_DUPLICATE_TITLE_SUFFIX_KEY + ? CONTENT_DUPLICATE_TITLE_SUFFIX + : value; + } catch { + return CONTENT_DUPLICATE_TITLE_SUFFIX; + } +}; + +/** Narrows a service to its duplicate method, refusing a content type without it. */ +export const duplicationMethods = ( + definition: AnyContentTypeDefinition, + service: object, +): TMethods => { + if ( + !definition.duplication.enabled || + typeof (service as { duplicate?: unknown }).duplicate !== "function" + ) { + throw new ContentEngineError( + "duplicate needs `duplication: { enabled: true }` on the content type.", + { contentTypeId: definition.id }, + ); + } + + return service as TMethods; +}; diff --git a/packages/vitnode/src/content/server/editorial-effects.ts b/packages/vitnode/src/content/server/editorial-effects.ts index fcc21eb33a..e329b8201c 100644 --- a/packages/vitnode/src/content/server/editorial-effects.ts +++ b/packages/vitnode/src/content/server/editorial-effects.ts @@ -51,6 +51,12 @@ const payloadFor = ( }; switch (outcome.operation) { + case "create": + // Absent rather than `undefined` for an ordinary create, so + // `"duplicatedFromId" in payload` is how a listener tells a copy apart. + return outcome.duplicatedFromId === undefined + ? base + : { ...base, duplicatedFromId: outcome.duplicatedFromId }; case "publish": { const publishedAt = (outcome.row as { publishedAt?: unknown }) .publishedAt; diff --git a/packages/vitnode/src/content/server/editorial-service.ts b/packages/vitnode/src/content/server/editorial-service.ts index f014c04269..9f249dc359 100644 --- a/packages/vitnode/src/content/server/editorial-service.ts +++ b/packages/vitnode/src/content/server/editorial-service.ts @@ -1,6 +1,7 @@ import type { SQL } from "drizzle-orm"; import type { PgColumn, + PgTable, PgTableWithColumns, TableConfig, } from "drizzle-orm/pg-core"; @@ -8,6 +9,7 @@ import type { Context } from "hono"; import { and, eq, ne, sql } from "drizzle-orm"; +import type { ContentId } from "../ids"; import type { ContentActor, ContentRevisionOperation } from "../revisions"; import type { ContentSchemas } from "../schemas"; import type { @@ -15,6 +17,7 @@ import type { ContentChangedPath, ContentCreateInput, ContentInnerFieldsOf, + ContentLocalizedValues, ContentRelationCollectionName, ContentRepeatableFieldName, ContentRepeatableInputRow, @@ -25,9 +28,12 @@ import type { } from "../types"; import type { ContentAdvancedStore } from "./advanced-store"; import type { ContentDeliveryOutcome } from "./delivery-writes"; +import type { ContentEditorialDuplicationMethods } from "./duplicate"; import type { ContentRevisionsModel } from "./revisions-model"; import type { ContentSchedulesModel } from "./schedules-model"; import type { ContentDatabase } from "./service"; +import type { ContentTranslationEditorialService } from "./translation-editorial-service"; +import type { ContentTranslationModel } from "./translation-model"; import { isContentPubliclyVisible } from "../cache"; import { @@ -51,6 +57,7 @@ import { applyContentDeliveryWrite, contentSlugHistoryFor, } from "./delivery-writes"; +import { runContentDuplicate } from "./duplicate"; import { assertContentFileReferences, contentFileCollectionFields, @@ -78,6 +85,8 @@ export interface ContentEditorialOutcome { changedFields: ContentChangedPath[]; delivery?: ContentDeliveryOutcome; + /** Set by `duplicate` on the copy's `create`: the record it was copied from. */ + duplicatedFromId?: ContentId; movedTranslations?: number; operation: ContentRevisionOperation; @@ -108,7 +117,15 @@ export interface ContentEditorialPublicationOptions extends ContentEditorialOpti expectedVersion?: number; } -export interface ContentEditorialService { +export type ContentEditorialService = + ContentEditorialServiceBase & + (TDefinition extends { duplication: { enabled: true } } + ? ContentEditorialDuplicationMethods + : Partial< + Record, never> + >); + +export interface ContentEditorialServiceBase { create: ( values: ContentCreateInput, options: ContentEditorialOptions, @@ -227,6 +244,7 @@ export const createContentEditorialService = < pluginId, schemas, table, + translations, }: { /** The collection store, or nothing for a content type that declares none. */ advanced?: ContentAdvancedStore; @@ -245,6 +263,16 @@ export const createContentEditorialService = < pluginId: string; schemas: ContentSchemas; table: PgTableWithColumns; + /** + * The translation half, for a localized content type: `duplicate` copies every + * language through its editorial create, in the copy's transaction. + */ + translations?: { + columns: Record; + editorial: () => ContentTranslationEditorialService; + model: () => ContentTranslationModel; + table: PgTable; + }; }): ContentEditorialService => { if (!definition.editorial.enabled) { throw new ContentEngineError( @@ -795,7 +823,7 @@ export const createContentEditorialService = < }) as ContentEditorialRepeatableMethods; } - const editorial: ContentEditorialService = { + const editorial: ContentEditorialServiceBase = { create: async (values, options) => await transact(options, async tx => { const parsed = schemas.create.parse(values) as Record; @@ -1081,7 +1109,7 @@ export const createContentEditorialService = < relations: mutableRelations, repeatable: - mutableRepeatables as ContentEditorialService["repeatable"], + mutableRepeatables as ContentEditorialServiceBase["repeatable"], revisions, @@ -1198,5 +1226,65 @@ export const createContentEditorialService = < }), }; - return editorial; + // The copy goes through `editorial.create` and each language through its own + // editorial create, so every one of them starts with its `create` revision. + const duplication: ContentEditorialDuplicationMethods = { + duplicate: async (sourceId, options) => { + const result = await runContentDuplicate( + { + advanced: store, + c, + columns, + definition, + pluginId, + table, + translation: translations, + }, + sourceId, + options, + { + base: async (values, tx) => + await editorial.create(values as ContentCreateInput, { + actor: options.actor, + tx, + }), + idOf: outcome => (outcome.row as { id: number }).id, + translation: async (itemId, locale, values, tx) => { + if (!translations) { + throw new ContentEngineError( + "Duplicating a translation needs the translation editorial service.", + { contentTypeId }, + ); + } + + return await translations + .editorial() + .create( + itemId, + locale, + values as ContentLocalizedValues, + { actor: options.actor, tx }, + ); + }, + }, + ); + + return result + ? { + ...result.base, + duplicatedFromId: sourceId, + skippedLocales: result.skippedLocales, + sourceId, + translations: result.translations, + } + : null; + }, + }; + + // The duplication half is resolved from `TDefinition["duplication"]`, still a + // type parameter here; the runtime flag reads the same field. + return { + ...editorial, + ...(definition.duplication.enabled ? duplication : {}), + } as ContentEditorialService; }; diff --git a/packages/vitnode/src/content/server/http-errors.ts b/packages/vitnode/src/content/server/http-errors.ts index 36228a7df3..ca36bce457 100644 --- a/packages/vitnode/src/content/server/http-errors.ts +++ b/packages/vitnode/src/content/server/http-errors.ts @@ -4,6 +4,7 @@ import { ZodError } from "zod"; import type { ContentConflict, ContentDeliveryConflict, + ContentDuplicateRejection, ContentUnprocessable, } from "../conflicts"; import type { ContentScheduleCode } from "../schedules"; @@ -12,10 +13,13 @@ import { PG_ERROR_CODES, pgErrorCode } from "../../lib/api/pg-error"; import { CONTENT_CONFLICT_CODES, CONTENT_DELIVERY_CODES, + CONTENT_DUPLICATE_CODES, CONTENT_UNPROCESSABLE_CODES, } from "../const"; import { ContentDeliverySlugReserved, + ContentDuplicateSlugConflict, + ContentDuplicateUniqueRequired, ContentInputError, ContentRevisionNotRestorable, ContentScheduleError, @@ -48,6 +52,15 @@ export const contentUnprocessable = ( body: ContentUnprocessable, ): HTTPException => jsonError(422, body); +/** A duplicate refused for a reason a form can act on: 409 slug, 422 unique. */ +export const contentDuplicateRejected = ( + body: ContentDuplicateRejection, +): HTTPException => + jsonError( + body.code === CONTENT_DUPLICATE_CODES.slugConflict ? 409 : 422, + body, + ); + export const contentFileRejected = (body: { code: string; field: string; @@ -96,6 +109,24 @@ export const rethrowAsHttpError = ( }); } + if (error instanceof ContentDuplicateSlugConflict) { + throw contentDuplicateRejected({ + code: CONTENT_DUPLICATE_CODES.slugConflict, + contentTypeId: error.contentTypeId ?? contentTypeId ?? "", + field: error.field, + locale: error.locale, + slug: error.slug, + }); + } + + if (error instanceof ContentDuplicateUniqueRequired) { + throw contentDuplicateRejected({ + code: CONTENT_DUPLICATE_CODES.uniqueRequired, + contentTypeId: error.contentTypeId ?? contentTypeId ?? "", + fields: error.fields, + }); + } + if (error instanceof ContentScheduleError) { throw contentScheduleRejected({ code: error.code, diff --git a/packages/vitnode/src/content/server/index.ts b/packages/vitnode/src/content/server/index.ts index 83e4ed02c6..564a8df858 100644 --- a/packages/vitnode/src/content/server/index.ts +++ b/packages/vitnode/src/content/server/index.ts @@ -49,6 +49,29 @@ export type { ContentSearchDriftLocale, ContentTypeDiagnostic, } from "./diagnostics"; +export { + appendContentTitleSuffix, + contentSlugCandidate, + duplicationMethods, + resolveContentTitleSuffix, + runContentDuplicate, +} from "./duplicate"; +export type { + ContentDuplicateDependencies, + ContentDuplicateOptions, + ContentDuplicateResult, + ContentDuplicateTranslationSource, + ContentDuplicateWriter, + ContentDuplicationMethods, + ContentEditorialDuplicateOptions, + ContentEditorialDuplicateOutcome, + ContentEditorialDuplicationMethods, +} from "./duplicate"; +export { contentDuplicateEffects } from "./duplicate-effects"; +export type { + ContentDuplicateEffectsInput, + ContentDuplicateEffectsOptions, +} from "./duplicate-effects"; export { contentEditorialEffects } from "./editorial-effects"; export type { ContentEditorialEffectsOptions, @@ -60,6 +83,7 @@ export type { ContentEditorialOutcome, ContentEditorialPublicationOptions, ContentEditorialService, + ContentEditorialServiceBase, ContentEditorialWriteOptions, } from "./editorial-service"; export { diff --git a/packages/vitnode/src/content/server/model.ts b/packages/vitnode/src/content/server/model.ts index 8299cdaf7c..ae32482a4a 100644 --- a/packages/vitnode/src/content/server/model.ts +++ b/packages/vitnode/src/content/server/model.ts @@ -180,6 +180,15 @@ export const createContentModel = < ? { columns: translationColumns, table: translationTable } : undefined; + /** + * The translation half `duplicate` copies through, per request. `undefined` + * for a content type without localization, which has nothing to copy there. + */ + const duplicateTranslations = (c: Context) => + translationSearch && translationSchemas + ? { ...translationSearch, model: () => buildTranslations(c) } + : undefined; + const buildTranslations = ( c: Context, ): ContentTranslationModel => { @@ -281,6 +290,23 @@ export const createContentModel = < pluginId, schemas, table, + translations: (() => { + const half = duplicateTranslations(c); + + return half && translationSchemas + ? { + ...half, + editorial: () => + createContentTranslationEditorialService({ + c, + definition, + pluginId, + schemas: translationSchemas, + translations: half.model(), + }), + } + : undefined; + })(), }) : undefined, localization: definition.localization, @@ -318,7 +344,7 @@ export const createContentModel = < definition, schemas, table, - translation: translationSearch, + translation: duplicateTranslations(c), }), translations, }); @@ -357,7 +383,7 @@ export const createContentModel = < definition, schemas, table, - translation: translationSearch, + translation: duplicateTranslations(c), }), table, translationColumns, diff --git a/packages/vitnode/src/content/server/routes.ts b/packages/vitnode/src/content/server/routes.ts index 6e4713c709..f759250d21 100644 --- a/packages/vitnode/src/content/server/routes.ts +++ b/packages/vitnode/src/content/server/routes.ts @@ -10,6 +10,10 @@ import type { ContentOrderableFieldName, ContentReferenceFieldName, } from "../types"; +import type { + ContentDuplicationMethods, + ContentEditorialDuplicationMethods, +} from "./duplicate"; import type { AnyContentModel, ContentModel } from "./model"; import type { ContentPreviewTarget } from "./preview-target"; @@ -24,6 +28,7 @@ import { contentTypeName } from "../admin/labels"; import { zodContentConflict, zodContentDeliveryConflict, + zodContentDuplicateRejection, zodContentScheduleRejection, zodContentUnprocessable, } from "../conflicts"; @@ -45,9 +50,12 @@ import { zodContentFileFieldValue, zodContentFileReferenceRejection, } from "../files"; +import { contentIdSchema } from "../ids"; import { partitionContentFields } from "../localization"; import { orderableColumns } from "../registry"; import { resolveContentActor } from "./actor"; +import { duplicationMethods } from "./duplicate"; +import { contentDuplicateEffects } from "./duplicate-effects"; import { contentEditorialEffects } from "./editorial-effects"; import { emitContentEvent } from "./emit"; import { @@ -880,6 +888,166 @@ export const buildContentRoutes = < }, }); + /** + * The patch shape of `schemas.update` without its "at least one field" rule, so + * an empty `overrides` means "copy as is" rather than a 400. Read off the + * object Zod keeps behind the refinement; never a default, so an omitted field + * keeps the source's value instead of the field's default. + */ + const patchObject = (schema: z.ZodType): z.ZodObject => + z.strictObject((schema as unknown as z.ZodObject).shape); + + const translationSchemas = model.translationSchemas; + const duplicateBody = z.strictObject({ + /** Shared values for the copy. A group merges leaf by leaf. */ + overrides: patchObject(schemas.update).optional(), + ...(translationSchemas + ? { + /** Localized values per copied locale, merged the same way. */ + translations: z + .record( + z.string().min(1).max(CONTENT_LOCALE_MAX_LENGTH), + patchObject(translationSchemas.update), + ) + .optional(), + } + : {}), + }); + + const duplicateResponse = z.object({ + /** The copy's identifier - where the AdminCP navigates next. */ + id: contentIdSchema(definition.idStrategy), + /** The copied translations' locales, default locale first. */ + locales: z.array(z.string()), + row: schemas.selectObject, + /** Source locales switched off in this install, which the copy does not carry. */ + skippedLocales: z.array(z.string()), + sourceId: contentIdSchema(definition.idStrategy), + }); + + /** + * `POST /{id}/duplicate` - a draft copy of one record, its collections and every + * translation, in one transaction. Creating is what it does, so `can_create` + * gates it; reading the source is what it needs, so `can_view` is checked too. + * Mounted only for a content type with `duplication: { enabled: true }`. + */ + const duplicate = buildRoute({ + pluginId, + adminStaffPermission: { module, permission: CONTENT_PERMISSIONS.create }, + route: { + method: "post", + path: "/{id}/duplicate", + description: `Duplicate a ${name} as a draft`, + request: { params: schemas.params, body: jsonBody(duplicateBody) }, + responses: { + 201: jsonResponse(duplicateResponse, `${name} duplicated as a draft`), + 400: writeRejection("Invalid identifier or overrides"), + 403: { description: "Missing can_create or can_view" }, + 404: { description: `${name} not found` }, + 409: jsonResponse( + z.union([ + zodContentConflict, + zodContentDeliveryConflict, + zodContentDuplicateRejection, + ]), + "No free slug for the copy, or an override collides with another record", + ), + 422: jsonResponse( + zodContentDuplicateRejection, + "A unique field needs an override", + ), + }, + }, + handler: async c => { + const canView = await checkStaffPermission(c, { + module, + permission: CONTENT_PERMISSIONS.view, + plugin: pluginId, + type: "admin", + }); + if (!canView) { + throw new HTTPException(403, { + message: `You do not have permission to view this ${name}.`, + }); + } + + const id = identifier(c); + const body = c.req.header("content-type")?.includes("json") + ? await readJson(c, duplicateBody) + : {}; + const overrides = body.overrides; + const translations = (body as { translations?: Record }) + .translations; + const actor = resolveContentActor(c); + + if (editorial) { + const outcome = await withHttpErrors( + "create", + async () => + await duplicationMethods< + ContentEditorialDuplicationMethods + >(definition, editorialService(c)).duplicate(id as never, { + actor, + overrides: overrides as never, + translations: translations as never, + }), + { contentTypeId: definition.id, itemId: id, structured: true }, + ); + if (!outcome) throw notFound(definition); + + await contentDuplicateEffects( + c, + model, + { kind: "editorial", outcome: outcome }, + { actorUserId: actor.userId, pluginId }, + ); + + return c.json( + { + id: (outcome.row as { id: number }).id, + locales: outcome.translations.map(entry => entry.locale), + row: outcome.row, + skippedLocales: outcome.skippedLocales, + sourceId: id, + }, + 201, + ); + } + + const result = await withHttpErrors( + "create", + async () => + await duplicationMethods>( + definition, + model.service(c), + ).duplicate(id as never, { + overrides: overrides as never, + translations: translations as never, + }), + { contentTypeId: definition.id, itemId: id, structured: true }, + ); + if (!result) throw notFound(definition); + + await contentDuplicateEffects( + c, + model, + { kind: "plain", result: result }, + { actorUserId: actor.userId, pluginId }, + ); + + return c.json( + { + id: (result.row as { id: number }).id, + locales: result.translations.map(entry => entry.locale), + row: result.row, + skippedLocales: result.skippedLocales, + sourceId: id, + }, + 201, + ); + }, + }); + /** * The editorial `PUT`: same path and method, one extra key in the body. * @@ -1764,6 +1932,9 @@ export const buildContentRoutes = < ...(definition.publication.enabled ? [publicationRoute("publish"), publicationRoute("unpublish")] : []), + // Mounted only when the content type opts in, so no other content type gains + // an endpoint - or a permission row in the matrix - it does not use. + ...(definition.duplication.enabled ? [duplicate] : []), ...(editorial ? [revisionList, revisionDetail, restore] : []), ...(definition.liveEditing.enabled ? buildContentLiveRoutes(model, { pluginId }) diff --git a/packages/vitnode/src/content/server/service.ts b/packages/vitnode/src/content/server/service.ts index d6de6c68c8..6031ff64be 100644 --- a/packages/vitnode/src/content/server/service.ts +++ b/packages/vitnode/src/content/server/service.ts @@ -19,6 +19,7 @@ import type { ContentDetail, ContentFilterInput, ContentInnerFieldsOf, + ContentLocalizedValues, ContentOrderableFieldName, ContentReferenceFieldName, ContentRelationCollectionName, @@ -30,7 +31,9 @@ import type { ContentValuesOf, } from "../types"; import type { ContentAdvancedStore } from "./advanced-store"; +import type { ContentDuplicationMethods } from "./duplicate"; import type { ContentPickerTarget } from "./references"; +import type { ContentTranslationModel } from "./translation-model"; import { withPagination } from "../../api/lib/with-pagination"; import { @@ -49,6 +52,7 @@ import { buildContentRepeatableOperations, contentCollectionKinds, } from "./collection-api"; +import { runContentDuplicate } from "./duplicate"; import { assertContentFileReferences, contentFileCollectionFields, @@ -221,6 +225,9 @@ export interface ContentPublicationMethods { } export type ContentService = ContentServiceBase & + (TDefinition extends { duplication: { enabled: true } } + ? ContentDuplicationMethods + : Partial, never>>) & (TDefinition extends { publication: { enabled: true } } ? ContentPublicationMethods : Partial, never>>); @@ -312,6 +319,8 @@ export const createContentService = < table: PgTableWithColumns; translation?: { columns: Record; + /** The translation model, which `duplicate` copies every language through. */ + model?: () => ContentTranslationModel; table: PgTable; }; }): ContentService => { @@ -1129,6 +1138,64 @@ export const createContentService = < }) as ContentRepeatableMethods; } + // One transaction, through `service.create` and the translation model's own + // `create` - the same pipeline every other write takes. + const duplicationMethods: ContentDuplicationMethods = { + duplicate: async (sourceId, options = {}) => { + const translationModel = translation?.model; + const result = await runContentDuplicate( + { + advanced: store, + c, + columns, + definition, + table, + translation: + translation && translationModel + ? { + columns: translation.columns, + model: translationModel, + table: translation.table, + } + : undefined, + }, + sourceId, + options, + { + base: async (values, tx) => + await service.create(values as ContentCreateInput, { + tx, + }), + idOf: row => (row as { id: number }).id, + translation: async (itemId, locale, values, tx) => { + if (!translationModel) { + throw new ContentEngineError( + "Duplicating a translation needs the translation model.", + { contentTypeId }, + ); + } + + return await translationModel().create( + itemId, + locale, + values as ContentLocalizedValues, + { tx }, + ); + }, + }, + ); + + return result + ? { + row: result.base, + skippedLocales: result.skippedLocales, + sourceId, + translations: result.translations, + } + : null; + }, + }; + // `ContentService` resolves its publication half from // `TDefinition["publication"]["enabled"]`, which is still a type parameter // here - so TypeScript cannot check the object against a branch it has not @@ -1138,5 +1205,6 @@ export const createContentService = < return { ...service, ...(publication ? publicationMethods : {}), + ...(definition.duplication.enabled ? duplicationMethods : {}), } as ContentService; }; diff --git a/packages/vitnode/src/lib/api/pg-error.ts b/packages/vitnode/src/lib/api/pg-error.ts index 2628b1eeda..19351d991b 100644 --- a/packages/vitnode/src/lib/api/pg-error.ts +++ b/packages/vitnode/src/lib/api/pg-error.ts @@ -18,6 +18,24 @@ export const pgErrorCode = (error: unknown, depth = 0): string | undefined => { return pgErrorCode(cause, depth + 1); }; +/** The constraint a Postgres error names (`constraint_name`), through driver wrappers. */ +export const pgConstraintName = ( + error: unknown, + depth = 0, +): string | undefined => { + if (typeof error !== "object" || error === null || depth > 3) { + return undefined; + } + + const { cause, constraint_name: name } = error as { + cause?: unknown; + constraint_name?: unknown; + }; + if (typeof name === "string" && name !== "") return name; + + return pgConstraintName(cause, depth + 1); +}; + export const isPgReferenceViolation = (error: unknown): boolean => { const code = pgErrorCode(error); diff --git a/packages/vitnode/src/locales/api/en.json b/packages/vitnode/src/locales/api/en.json index c1ab67e067..577258b850 100644 --- a/packages/vitnode/src/locales/api/en.json +++ b/packages/vitnode/src/locales/api/en.json @@ -54,6 +54,11 @@ "test_title": "Notification emails work", "test_body": "If you can read this, notification emails from this site reach your inbox. No action needed." } + }, + "content": { + "duplicate": { + "title_suffix": "(Copy)" + } } } } diff --git a/packages/vitnode/src/tanstack/admin/content/query.ts b/packages/vitnode/src/tanstack/admin/content/query.ts index 417584ed0c..f397c32187 100644 --- a/packages/vitnode/src/tanstack/admin/content/query.ts +++ b/packages/vitnode/src/tanstack/admin/content/query.ts @@ -4,6 +4,8 @@ import type { ContentPublicationAction } from "@/content/publication"; import type { AnyContentTypeDefinition } from "@/content/types"; import type { ContentApiTarget } from "@/views/admin/views/content/content-request"; import type { + ContentDuplicateInput, + ContentDuplicateMutationResult, ContentRowMutationArgs, ContentRowMutationResult, } from "@/views/admin/views/content/table/list-mutations"; @@ -17,6 +19,7 @@ import { } from "@/views/admin/views/content/lib/invalidate"; import { deleteContentInBrowser, + duplicateContentInBrowser, setContentPublicationInBrowser, } from "@/views/admin/views/content/table/list-mutations"; import { @@ -140,6 +143,25 @@ export const setContentPublication = async ( return result; }; +/** Copies a row as a draft, then refreshes the list it will appear in. */ +export const duplicateContentRow = async ( + queryClient: QueryClient, + { + contentTypeId, + id, + input, + target, + }: ContentRowWriteArgs & { input?: ContentDuplicateInput }, +): Promise => { + const result = await duplicateContentInBrowser({ id, input, target }); + + if (result.error === undefined) { + await invalidateContentAfterWrite(queryClient, { contentTypeId }); + } + + return result; +}; + /** Deletes a row, then drops everything that was about it. */ export const deleteContentRow = async ( queryClient: QueryClient, diff --git a/packages/vitnode/src/tests/content-fixtures.ts b/packages/vitnode/src/tests/content-fixtures.ts index c79914bf21..a0208e6075 100644 --- a/packages/vitnode/src/tests/content-fixtures.ts +++ b/packages/vitnode/src/tests/content-fixtures.ts @@ -618,3 +618,87 @@ export const testFileGalleryContentType = defineContentType({ list: { columns: ["cover", "title", "status", "updatedAt"] }, }, }); + +/** + * Duplication on the widest shape it has to copy: editorial, localized, a + * delivery address with redirects, every collection kind and a nullable unique + * field. The short `maxLength` on the title is what the suffix trimming is + * measured against. + */ +export const testDuplicableArticleContentType = defineContentType({ + id: "test.duplicable-article", + tableName: "test_duplicable_articles", + localization: { defaultLocale: "en", fallback: "default" }, + publication: true, + editorial: true, + duplication: true, + fields: { + title: field.text({ + localized: true, + required: true, + minLength: 1, + maxLength: 20, + }), + slug: field.slug({ localized: true, source: "title" }), + seo: field.group({ + localized: true, + nullable: true, + fields: { + description: field.textarea({ nullable: true, maxLength: 200 }), + }, + }), + code: field.text({ unique: true, nullable: true, maxLength: 40 }), + featured: field.boolean({ defaultValue: false }), + startsAt: field.dateTime({ nullable: true }), + author: field.user(), + reviewers: field.user({ multiple: true, ordered: true }), + categories: field.relation({ + multiple: true, + ordered: true, + target: () => testCategoryContentType, + }), + cover: field.file({ maxBytes: 5 * 1024 * 1024 }), + gallery: field.file({ multiple: true, maxBytes: 5 * 1024 * 1024 }), + faq: field.repeatable({ + fields: { + question: field.text({ required: true, maxLength: 200 }), + answer: field.textarea({ required: true }), + }, + }), + }, + publicApi: { + path: "duplicable-articles", + fields: ["id", "title", "slug", "faq.question", "publishedAt"], + defaultOrderBy: "publishedAt", + }, + search: { + titleField: "title", + contentFields: ["title", "faq.question"], + pathTemplate: "/{locale}/duplicable-articles/{slug}", + }, + delivery: { redirects: true }, + admin: { + titleField: "title", + list: { columns: ["status", "updatedAt"] }, + }, +}); + +/** + * Duplication without editorial or localization: the plain service path, with a + * required unique field every copy has to be given a new value for. + */ +export const testDuplicableNoteContentType = defineContentType({ + id: "test.duplicable-note", + tableName: "test_duplicable_notes", + publication: true, + duplication: true, + fields: { + title: field.text({ required: true, minLength: 1, maxLength: 200 }), + slug: field.slug({ source: "title" }), + code: field.text({ required: true, unique: true, maxLength: 40 }), + }, + admin: { + titleField: "title", + list: { columns: ["title", "status", "updatedAt"] }, + }, +}); diff --git a/packages/vitnode/src/views/admin/views/content/content-mutation.ts b/packages/vitnode/src/views/admin/views/content/content-mutation.ts index 738d13c56d..8a01677cd5 100644 --- a/packages/vitnode/src/views/admin/views/content/content-mutation.ts +++ b/packages/vitnode/src/views/admin/views/content/content-mutation.ts @@ -1,6 +1,7 @@ import type { ContentConflict, ContentDeliveryConflict, + ContentDuplicateRejection, ContentScheduleRejection, ContentTranslationConflict, ContentUnprocessable, @@ -37,6 +38,8 @@ export interface ContentMutationResult { conflict?: ContentConflict; delivery?: ContentDeliveryConflict; + /** Why a duplicate was refused: no free slug (409) or a unique field (422). */ + duplicate?: ContentDuplicateRejection; error?: string; id?: number; diff --git a/packages/vitnode/src/views/admin/views/content/lib/api-result.ts b/packages/vitnode/src/views/admin/views/content/lib/api-result.ts index bad618bd81..cb0f852b02 100644 --- a/packages/vitnode/src/views/admin/views/content/lib/api-result.ts +++ b/packages/vitnode/src/views/admin/views/content/lib/api-result.ts @@ -3,6 +3,7 @@ import type { z } from "zod"; import { parseContentConflict, parseContentDeliveryConflict, + parseContentDuplicateRejection, parseContentScheduleRejection, parseContentTranslationConflict, parseContentUnprocessable, @@ -71,6 +72,7 @@ export const contentFailureResult = (result: { }): ContentMutationResult => ({ conflict: parseContentConflict(result.error) ?? undefined, delivery: parseContentDeliveryConflict(result.error) ?? undefined, + duplicate: parseContentDuplicateRejection(result.error) ?? undefined, error: result.error ?? "", rejection: parseContentScheduleRejection(result.error) ?? undefined, status: result.status, diff --git a/packages/vitnode/src/views/admin/views/content/table/list-mutations.ts b/packages/vitnode/src/views/admin/views/content/table/list-mutations.ts index 0a74b85acd..64936b2a6a 100644 --- a/packages/vitnode/src/views/admin/views/content/table/list-mutations.ts +++ b/packages/vitnode/src/views/admin/views/content/table/list-mutations.ts @@ -60,6 +60,69 @@ export const setContentPublicationInBrowser = async ({ }), ); +export interface ContentDuplicateInput { + /** Shared values for the copy. A group merges leaf by leaf. */ + overrides?: Record; + /** Localized values per copied locale, merged the same way. */ + translations?: Record>; +} + +/** What `POST /{id}/duplicate` answers with on a `201`. */ +export interface ContentDuplicatePayload { + id: number; + /** The copied translations' locales, default locale first. */ + locales: string[]; + row: Record; + /** Source locales switched off in this install, which the copy does not carry. */ + skippedLocales: string[]; + sourceId: number; +} + +export type ContentDuplicateMutationResult = ContentRowMutationResult & { + /** Present on success: the copy, so the caller can open it. */ + duplicated?: ContentDuplicatePayload; +}; + +/** + * Copies one record as a draft. A refusal comes back with `duplicate` set when it + * names a field to fix - `CONTENT_DUPLICATE_SLUG_CONFLICT` (409) or + * `CONTENT_DUPLICATE_UNIQUE_REQUIRED` (422) - and with `status` either way. + */ +export const duplicateContentInBrowser = async ({ + id, + input = {}, + target, +}: ContentRowMutationArgs & { + input?: ContentDuplicateInput; +}): Promise => { + let response: Response; + + try { + response = await contentApiFetch({ + body: input, + method: "post", + path: `/${id}/duplicate`, + target, + }); + } catch { + return UNREACHABLE; + } + + if (!response.ok) { + return { + ...contentFailureResult({ + error: await response.text(), + status: response.status, + }), + status: response.status, + }; + } + + const duplicated = (await response.json()) as ContentDuplicatePayload; + + return { duplicated, id: duplicated.id, status: response.status }; +}; + /** * Deletes one record. * From 01c5493befd8a2730c2825cd1eb2195ab6e0c37a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 07:59:54 +0000 Subject: [PATCH 03/17] =?UTF-8?q?feat(content):=20=E2=9C=A8=20record-level?= =?UTF-8?q?=20hide=20and=20unhide?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Content types with `visibility: { enabled: true }` get generated `hiddenAt` / `hiddenBy` columns (FK core_users, ON DELETE SET NULL, indexed), and a hidden record is unavailable on every public read path regardless of publication: the SQL `publishedCondition` ANDs `hiddenAt IS NULL` and the JS `isContentPubliclyVisible` refuses a set `hiddenAt`, so list, detail, localized reads and fallback, delivery resolve and redirects, sitemap, hreflang alternates, public locales, search and diagnostics all agree. Previews keep working. - Plain and editorial `hide` / `unhide` services (idempotent, optional `expectedVersion`, `hide`/`unhide` revisions, delivery bookkeeping), present only when visibility is enabled and typed with `ContentIdOf`. - `POST /{id}/hide` and `/{id}/unhide` behind `can_hide`, effects after the commit: hidden/unhidden events, private/public search documents, delivery events, and front-end revalidation via `dispatchContentRevalidation`. - Admin list `visibility=hidden|visible` filter next to `status`. - Restore never changes visibility; publish/unpublish and scheduled transitions keep a hidden record hidden and now report the real before-state (the schedule executor no longer assumes an unpublish came from a public row). - Typed AdminCP client helpers `setContentVisibilityInBrowser` and `setContentHiddenInBrowser`. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VpG3D33ZqXEYjN2N6eNcMK --- .../helpers/execute-content-schedule.test.ts | 23 ++ .../helpers/execute-content-schedule.ts | 17 +- packages/vitnode/src/content/cache.test.ts | 38 +++ packages/vitnode/src/content/cache.ts | 10 + packages/vitnode/src/content/const.ts | 11 + packages/vitnode/src/content/define-fields.ts | 9 + packages/vitnode/src/content/define.ts | 2 + packages/vitnode/src/content/index.ts | 14 ++ packages/vitnode/src/content/indexes.ts | 7 +- packages/vitnode/src/content/registry.ts | 2 + packages/vitnode/src/content/revisions.ts | 7 + packages/vitnode/src/content/schemas.ts | 17 ++ .../src/content/server/column-builders.ts | 17 ++ .../src/content/server/editorial-effects.ts | 22 ++ .../src/content/server/editorial-service.ts | 158 ++++++++++++- packages/vitnode/src/content/server/index.ts | 21 ++ .../content/server/permission-matrix.test.ts | 6 + .../src/content/server/public-locales.ts | 7 + .../src/content/server/publication.test.ts | 21 ++ .../vitnode/src/content/server/publication.ts | 29 ++- .../vitnode/src/content/server/query.test.ts | 55 +++++ packages/vitnode/src/content/server/query.ts | 29 ++- .../src/content/server/revision-snapshot.ts | 7 + .../src/content/server/route-helpers.ts | 17 ++ packages/vitnode/src/content/server/routes.ts | 131 ++++++++++- .../src/content/server/schedule-effects.ts | 22 +- .../src/content/server/search-document.ts | 2 + .../src/content/server/search-indexer.ts | 19 +- .../src/content/server/search-sync.test.ts | 74 ++++++ .../vitnode/src/content/server/search-sync.ts | 22 +- .../vitnode/src/content/server/service.ts | 83 +++++++ packages/vitnode/src/content/server/table.ts | 11 +- .../src/content/server/translation-model.ts | 19 +- packages/vitnode/src/content/server/types.ts | 28 ++- .../src/content/server/visibility-effects.ts | 136 +++++++++++ .../src/content/server/visibility.test-d.ts | 85 +++++++ .../vitnode/src/content/server/visibility.ts | 217 ++++++++++++++++++ packages/vitnode/src/content/types.ts | 11 + packages/vitnode/src/content/visibility.ts | 43 ++++ packages/vitnode/src/locales/en.json | 4 +- .../vitnode/src/tests/content-fixtures.ts | 88 +++++++ .../content/actions/history/revision-row.tsx | 8 + .../admin/views/content/form/mutations-api.ts | 32 +++ .../views/content/table/list-mutations.ts | 30 +++ 44 files changed, 1568 insertions(+), 43 deletions(-) create mode 100644 packages/vitnode/src/content/server/visibility-effects.ts create mode 100644 packages/vitnode/src/content/server/visibility.test-d.ts create mode 100644 packages/vitnode/src/content/server/visibility.ts create mode 100644 packages/vitnode/src/content/visibility.ts diff --git a/packages/vitnode/src/api/modules/content/helpers/execute-content-schedule.test.ts b/packages/vitnode/src/api/modules/content/helpers/execute-content-schedule.test.ts index 9a0f103a77..f28815f153 100644 --- a/packages/vitnode/src/api/modules/content/helpers/execute-content-schedule.test.ts +++ b/packages/vitnode/src/api/modules/content/helpers/execute-content-schedule.test.ts @@ -331,6 +331,29 @@ describe("executeContentSchedule", () => { expect(dispatchedPayload(dispatch).payload.wasPublic).toBe(true); }); + it("says a hidden record was never public before an unpublish", async () => { + // The transition writes `status` only, so the hidden row it returns was + // hidden a moment ago too - and a hidden record is public nowhere. + const { c, dispatch } = harness({ + editorial: { + unpublish: vi.fn().mockResolvedValue({ + ...outcome, + operation: "unpublish", + row: { + ...row, + hiddenAt: new Date("2026-08-06T00:00:00.000Z"), + status: "draft", + }, + }), + }, + schedule: { ...pending, action: "unpublish" }, + }); + + await executeContentSchedule(c, { generation: 1, scheduleId: 55 }); + + expect(dispatchedPayload(dispatch).payload.wasPublic).toBe(false); + }); + it("is JSON, so the queue can store and replay it", async () => { const { c, dispatch } = harness(); diff --git a/packages/vitnode/src/api/modules/content/helpers/execute-content-schedule.ts b/packages/vitnode/src/api/modules/content/helpers/execute-content-schedule.ts index aefb1f7220..7d6524f144 100644 --- a/packages/vitnode/src/api/modules/content/helpers/execute-content-schedule.ts +++ b/packages/vitnode/src/api/modules/content/helpers/execute-content-schedule.ts @@ -11,6 +11,7 @@ import { claimContentSchedule, settleContentSchedule, } from "@/content/server/schedules-model"; +import { isContentBaseRowPublic } from "@/content/server/visibility"; /** What the run decided, so the task logs something worth reading. */ export interface ContentScheduleOutcome { @@ -50,10 +51,13 @@ const slugOf = ( * * `wasPublic` is derived rather than read back: the transition guards on the * state it is leaving (`status <> 'published'` to publish, `= 'published'` to - * unpublish), so a *changed* publish came from a non-public row and a changed - * unpublish from a public one. That removes the extra `SELECT` the old code did - * outside the lock, and removes with it the window where the answer could have - * been someone else's write. + * unpublish), so a *changed* publish came from a draft and a changed unpublish + * from a published row - and everything else on the row, `hiddenAt` included, is + * exactly what it was, because a transition writes nothing but `status`. So the + * before-state is the returned row with its status flipped back, and a hidden + * record reports "not public" on both sides. That removes the extra `SELECT` the + * old code did outside the lock, and removes with it the window where the answer + * could have been someone else's write. */ const effectsPayload = ({ claimed, @@ -89,7 +93,10 @@ const effectsPayload = ({ scheduleId: claimed.id, scheduledBy: claimed.createdBy, version: outcome.version, - wasPublic: claimed.action === "unpublish", + wasPublic: isContentBaseRowPublic({ + ...row, + status: claimed.action === "unpublish" ? "published" : "draft", + }), }; }; diff --git a/packages/vitnode/src/content/cache.test.ts b/packages/vitnode/src/content/cache.test.ts index eca5bc7284..98657cf6dd 100644 --- a/packages/vitnode/src/content/cache.test.ts +++ b/packages/vitnode/src/content/cache.test.ts @@ -6,6 +6,7 @@ import { contentPublicListTag, contentPublicSlugTag, isContentPubliclyVisible, + isContentTranslationPubliclyVisible, } from "./cache"; import { CONTENT_CACHE_TAG_MAX_LENGTH } from "./const"; @@ -106,6 +107,43 @@ describe("isContentPubliclyVisible", () => { }), ).toBe(false); }); + + it("refuses a hidden row whatever its publication says", () => { + expect( + isContentPubliclyVisible({ + hiddenAt: past, + publishedAt: past, + status: "published", + }), + ).toBe(false); + // The JSON form a schedule payload carries. + expect( + isContentPubliclyVisible({ + hiddenAt: past.toISOString(), + publishedAt: past, + status: "published", + }), + ).toBe(false); + }); + + it("judges a row without visibility - or not hidden - exactly as before", () => { + expect( + isContentPubliclyVisible({ + hiddenAt: null, + publishedAt: past, + status: "published", + }), + ).toBe(true); + }); + + it("takes every language down with a hidden base row", () => { + expect( + isContentTranslationPubliclyVisible({ + base: { hiddenAt: past, publishedAt: past, status: "published" }, + translation: { publishedAt: past, status: "published" }, + }), + ).toBe(false); + }); }); describe("the invalidation matrix", () => { diff --git a/packages/vitnode/src/content/cache.ts b/packages/vitnode/src/content/cache.ts index 17e571e694..10443cd49d 100644 --- a/packages/vitnode/src/content/cache.ts +++ b/packages/vitnode/src/content/cache.ts @@ -282,13 +282,21 @@ export const contentLocaleInvalidationMode = ( return unchanged ? "stale-while-revalidate" : "immediate"; }; +/** + * The JavaScript twin of `publishedCondition`. `hiddenAt` is optional so a row + * from a content type without `visibility` - or a translation row, which never + * carries one - is judged exactly as before; any set value means hidden. + */ export const isContentPubliclyVisible = ({ + hiddenAt, publishedAt, status, }: { + hiddenAt?: Date | null | string; publishedAt: Date | null | string | undefined; status: string | undefined; }): boolean => { + if (hiddenAt !== undefined && hiddenAt !== null) return false; if (status !== "published" || publishedAt === null) return false; if (publishedAt === undefined) return false; @@ -303,6 +311,8 @@ export const isContentTranslationPubliclyVisible = ({ translation, }: { base: { + /** Record-level hiding lives on the base row only. */ + hiddenAt?: Date | null | string; publishedAt: Date | null | string | undefined; status: string | undefined; }; diff --git a/packages/vitnode/src/content/const.ts b/packages/vitnode/src/content/const.ts index ad8dcd1c60..0942591dad 100644 --- a/packages/vitnode/src/content/const.ts +++ b/packages/vitnode/src/content/const.ts @@ -7,6 +7,15 @@ export const CONTENT_EDITORIAL_FIELDS = ["version"] as const; /** The two system columns `visibility: { enabled: true }` adds to the base table. */ export const CONTENT_VISIBILITY_FIELDS = ["hiddenAt", "hiddenBy"] as const; +/** The two record-level visibility mutations, and their route segments. */ +export const CONTENT_VISIBILITY_ACTIONS = ["hide", "unhide"] as const; + +/** + * The admin list's `visibility` filter. `hidden` is `hiddenAt IS NOT NULL`, + * `visible` is `hiddenAt IS NULL` - whatever the publication status says. + */ +export const CONTENT_VISIBILITY_FILTERS = ["hidden", "visible"] as const; + /** * How a content type's primary key is generated. `serial` is the default and the * only strategy a content type had before; see `ids.ts`. @@ -263,8 +272,10 @@ export const CONTENT_SEARCH_PATH_MAX_LENGTH = 512; export const CONTENT_REVISION_OPERATIONS = [ "create", "delete", + "hide", "publish", "restore", + "unhide", "unpublish", "update", ] as const; diff --git a/packages/vitnode/src/content/define-fields.ts b/packages/vitnode/src/content/define-fields.ts index 25d7536955..f26c3d2c93 100644 --- a/packages/vitnode/src/content/define-fields.ts +++ b/packages/vitnode/src/content/define-fields.ts @@ -94,6 +94,15 @@ export const assertFieldName = ( ); } + // Not a column, but the admin list's filter key: `?visibility=hidden` has to + // mean one thing, and a field of that name would make it mean two. + if (visibility && name === "visibility") { + throw new ContentEngineError( + `"visibility" is the admin list's hidden/visible filter once \`visibility\` is enabled, so it cannot also be a field name. Rename the field.`, + { contentTypeId: id }, + ); + } + if (!CONTENT_FIELD_NAME_PATTERN.test(name)) { throw new ContentEngineError( `Field "${name}" must be camelCase and start with a lowercase letter.`, diff --git a/packages/vitnode/src/content/define.ts b/packages/vitnode/src/content/define.ts index 099c138f8a..2f505866a0 100644 --- a/packages/vitnode/src/content/define.ts +++ b/packages/vitnode/src/content/define.ts @@ -440,6 +440,7 @@ export const defineContentType = < fields: sharedColumns, publication: publicationEnabled, tableName, + visibility: visibilityEnabled, }); // Every declared field, in declaration order: the AdminCP renders one form, @@ -600,6 +601,7 @@ export const defineContentType = < localization: resolvedLocalization, publicApi: resolvedPublicApi, publication: publicationEnabled, + visibility: visibilityEnabled, }), search: resolvedSearch as ResolvedContentSearchConfig< ContentSearchEnabled diff --git a/packages/vitnode/src/content/index.ts b/packages/vitnode/src/content/index.ts index e091723047..d3605ce0c8 100644 --- a/packages/vitnode/src/content/index.ts +++ b/packages/vitnode/src/content/index.ts @@ -167,6 +167,9 @@ export { CONTENT_TRANSLATION_SYSTEM_FIELDS, CONTENT_TRANSLATION_TABLE_SUFFIX, CONTENT_UNPROCESSABLE_CODES, + CONTENT_VISIBILITY_ACTIONS, + CONTENT_VISIBILITY_FIELDS, + CONTENT_VISIBILITY_FILTERS, isContentPublicationStatus, isContentSitemapChangeFrequency, isFilterableFieldKind, @@ -483,6 +486,8 @@ export type { ContentUpdateInput, ContentUserField, ContentValuesOf, + ContentVisibilityField, + ContentVisibilityFilter, DeliverableContentTypeDefinition, DuplicableContentTypeDefinition, EditorialContentTypeDefinition, @@ -507,3 +512,12 @@ export type { SchedulableContentTypeDefinition, SearchableContentTypeDefinition, } from "./types"; +export { + contentVisibilityTransition, + hasContentVisibility, + isContentHidden, +} from "./visibility"; +export type { + ContentVisibilityAction, + ContentVisibilityTransition, +} from "./visibility"; diff --git a/packages/vitnode/src/content/indexes.ts b/packages/vitnode/src/content/indexes.ts index b6ff7daa25..57afde6d87 100644 --- a/packages/vitnode/src/content/indexes.ts +++ b/packages/vitnode/src/content/indexes.ts @@ -174,7 +174,9 @@ const named = ( * 3. every foreign key (`relation`, `user` and `file` fields), * 4. `createdAt` and `updatedAt`, which back the default ordering, * 5. `(status, publishedAt)` when publication is enabled - one composite index - * serving both the published predicate and the default public ordering. + * serving both the published predicate and the default public ordering, + * 6. `hiddenBy` when visibility is enabled - the foreign key `ON DELETE SET NULL` + * scans when a user account is deleted. * * Two entries covering the same columns collapse into one: the first name wins, * and the index is unique if *any* of them asked for uniqueness. So declaring @@ -192,12 +194,14 @@ export const resolveContentIndexes = ({ fields, publication = false, tableName, + visibility = false, }: { contentTypeId: string; declared: readonly ContentIndexConfig[]; fields: ContentFieldMap; publication?: boolean; tableName: string; + visibility?: boolean; }): ResolvedContentIndex[] => { const seenNames = new Map(); const seenSignatures = new Set(); @@ -252,6 +256,7 @@ export const resolveContentIndexes = ({ ...(publication ? [named(tableName, { on: ["status", "publishedAt"] })] : []), + ...(visibility ? [named(tableName, { on: ["hiddenBy"] })] : []), ]; const bySignature = new Map(); diff --git a/packages/vitnode/src/content/registry.ts b/packages/vitnode/src/content/registry.ts index 70c382bed4..002cffd64f 100644 --- a/packages/vitnode/src/content/registry.ts +++ b/packages/vitnode/src/content/registry.ts @@ -498,6 +498,8 @@ export const contentFilterableFields = ( .filter(([, field]) => isFilterableFieldKind(field.kind)) .map(([name]) => name), ...(definition.publication.enabled ? ["status"] : []), + // The hidden/visible filter, carried through the list URL like `status`. + ...(definition.visibility.enabled ? ["visibility"] : []), ].sort((a, b) => a.localeCompare(b)); }; diff --git a/packages/vitnode/src/content/revisions.ts b/packages/vitnode/src/content/revisions.ts index 6b0bd508ea..a7874780bb 100644 --- a/packages/vitnode/src/content/revisions.ts +++ b/packages/vitnode/src/content/revisions.ts @@ -38,6 +38,13 @@ export interface ContentRevisionSnapshot { schemaVersion: number; updatedAt: string; version: number; + /** + * Present only for a content type with `visibility`. A record of what the + * visibility was at this version - never something a restore puts back: + * restoring an old version brings back its *field values*, and a record hidden + * today stays hidden. + */ + visibility?: { hiddenAt: null | string; hiddenBy: null | number }; } export interface ContentTranslationRevisionSnapshot { diff --git a/packages/vitnode/src/content/schemas.ts b/packages/vitnode/src/content/schemas.ts index b2da282c1e..93edafd888 100644 --- a/packages/vitnode/src/content/schemas.ts +++ b/packages/vitnode/src/content/schemas.ts @@ -32,6 +32,7 @@ import { CONTENT_RELATION_COLLECTION_MAX, CONTENT_SLUG_DEFAULT_LENGTH, CONTENT_SYSTEM_FIELDS, + CONTENT_VISIBILITY_FILTERS, isFilterableFieldKind, } from "./const"; import { zodContentFileDescriptor } from "./files"; @@ -695,6 +696,7 @@ export const buildContentSchemas = ({ localization = contentLocalizationDisabled(), publicApi = DISABLED_PUBLIC_API, publication = false, + visibility = false, }: { admin: ResolvedContentAdminConfig; advanced?: ResolvedContentAdvancedConfig; @@ -704,6 +706,8 @@ export const buildContentSchemas = ({ localization?: ResolvedContentLocalizationConfig; publicApi?: ResolvedContentPublicApiConfig; publication?: boolean; + /** Whether `hiddenAt` / `hiddenBy` exist on the base row. */ + visibility?: boolean; }): ContentSchemas => { // Everything below this line is about the base table, so it reads the shared // half only. The localized half gets its own schemas at the bottom. @@ -734,6 +738,15 @@ export const buildContentSchemas = ({ ? { version: z.number().int().positive() } : {}; + // Read-only as well: `hide` and `unhide` are the only writers. Returned so the + // AdminCP can badge a hidden row and say who hid it. + const visibilitySelectShape: z.ZodRawShape = visibility + ? { + hiddenAt: z.date().nullable(), + hiddenBy: z.number().int().nullable(), + } + : {}; + const selectShape: z.ZodRawShape = { id: z.number(), ...Object.fromEntries( @@ -744,6 +757,7 @@ export const buildContentSchemas = ({ ), ...publicationSelectShape, ...editorialSelectShape, + ...visibilitySelectShape, createdAt: z.date(), updatedAt: z.date(), }; @@ -809,6 +823,9 @@ export const buildContentSchemas = ({ ...(publication ? { status: z.enum(CONTENT_PUBLICATION_STATUSES).optional() } : {}), + ...(visibility + ? { visibility: z.enum(CONTENT_VISIBILITY_FILTERS).optional() } + : {}), }), // The declared form fields, narrowed to the ones this schema describes. A // localized name is not dropped by accident: `admin.form.fields` covers one diff --git a/packages/vitnode/src/content/server/column-builders.ts b/packages/vitnode/src/content/server/column-builders.ts index 38da0cc93c..a62dfec1ee 100644 --- a/packages/vitnode/src/content/server/column-builders.ts +++ b/packages/vitnode/src/content/server/column-builders.ts @@ -48,6 +48,23 @@ export const buildPublicationColumns = (): Record< .default("draft"), }); +/** + * `hiddenAt` and `hiddenBy` - added only when visibility is enabled. + * + * `hiddenAt` is the whole state: a record is hidden exactly when it is set, so + * there is no second flag that could disagree with it. `hiddenBy` is who did it, + * and goes back to `NULL` with the account rather than blocking its deletion. + */ +export const buildVisibilityColumns = ( + userReference: ColumnReferenceThunk, +): Record => ({ + hiddenAt: timestamp(), + hiddenBy: integer().references(userReference, { + onDelete: "set null", + onUpdate: "cascade", + }), +}); + export const buildEditorialColumns = (): Record< string, AnyPgColumnBuilder diff --git a/packages/vitnode/src/content/server/editorial-effects.ts b/packages/vitnode/src/content/server/editorial-effects.ts index e329b8201c..f8f42f855d 100644 --- a/packages/vitnode/src/content/server/editorial-effects.ts +++ b/packages/vitnode/src/content/server/editorial-effects.ts @@ -24,8 +24,10 @@ const EVENT_ACTION: Record< > = { create: "created", delete: "deleted", + hide: "hidden", publish: "published", restore: "restored", + unhide: "unhidden", unpublish: "unpublished", update: "updated", }; @@ -57,6 +59,26 @@ const payloadFor = ( return outcome.duplicatedFromId === undefined ? base : { ...base, duplicatedFromId: outcome.duplicatedFromId }; + // `ContentHiddenPayload` / `ContentUnhiddenPayload`. The revision and version + // ride along only when there is one - a content type without `editorial` + // has neither, and its payload is exactly the documented shape. + case "hide": + case "unhide": { + const row = outcome.row as { hiddenAt?: unknown; id?: unknown }; + const payload: Record = { + actorUserId: outcome.visibility?.actorUserId ?? null, + contentId: row.id, + ...(outcome.revisionId === null + ? {} + : { revisionId: outcome.revisionId, version: outcome.version }), + }; + + if (outcome.operation === "hide" && row.hiddenAt instanceof Date) { + payload.hiddenAt = row.hiddenAt; + } + + return payload; + } case "publish": { const publishedAt = (outcome.row as { publishedAt?: unknown }) .publishedAt; diff --git a/packages/vitnode/src/content/server/editorial-service.ts b/packages/vitnode/src/content/server/editorial-service.ts index 9f249dc359..95bd467552 100644 --- a/packages/vitnode/src/content/server/editorial-service.ts +++ b/packages/vitnode/src/content/server/editorial-service.ts @@ -34,12 +34,19 @@ import type { ContentSchedulesModel } from "./schedules-model"; import type { ContentDatabase } from "./service"; import type { ContentTranslationEditorialService } from "./translation-editorial-service"; import type { ContentTranslationModel } from "./translation-model"; +import type { + ContentEditorialVisibilityMethods, + ContentEditorialVisibilityOptions, + ContentVisibilityAction, + ContentVisibilityChange, + ContentVisibilityMembers, +} from "./visibility"; -import { isContentPubliclyVisible } from "../cache"; import { CONTENT_EDITORIAL_FIELDS, CONTENT_PUBLICATION_FIELDS, CONTENT_SYSTEM_FIELDS, + CONTENT_VISIBILITY_FIELDS, } from "../const"; import { ContentEngineError, @@ -77,6 +84,12 @@ import { import { createContentRevisionsModel } from "./revisions-model"; import { createContentSchedulesModel } from "./schedules-model"; import { createSlugNormalizer } from "./slugs"; +import { + isContentBaseRowPublic, + isInVisibilityState, + visibilityChange, + visibilityValues, +} from "./visibility"; export interface ContentEditorialOutcome { /** `false` when nothing moved: no write, no revision, no event, no tags. */ @@ -98,6 +111,11 @@ export interface ContentEditorialOutcome { revisionId: null | number; row: ContentSelect; version: number; + /** + * Set only by `hide` and `unhide`: who asked, the visibility columns before + * the write, and the public reachability on both sides of it. + */ + visibility?: ContentVisibilityChange; } export interface ContentEditorialOptions { @@ -119,6 +137,10 @@ export interface ContentEditorialPublicationOptions extends ContentEditorialOpti export type ContentEditorialService = ContentEditorialServiceBase & + ContentVisibilityMembers< + TDefinition, + ContentEditorialVisibilityMethods + > & (TDefinition extends { duplication: { enabled: true } } ? ContentEditorialDuplicationMethods : Partial< @@ -316,6 +338,10 @@ export const createContentEditorialService = < ...CONTENT_SYSTEM_FIELDS, ...(publication ? CONTENT_PUBLICATION_FIELDS : []), ...CONTENT_EDITORIAL_FIELDS, + // Selected on every read and returned by every write, so each "was it + // public?" this service asks - delivery, the transition's before-state - + // sees `hiddenAt` without a second query. + ...(definition.visibility.enabled ? CONTENT_VISIBILITY_FIELDS : []), ]; const ownColumnNames = [ ...generatedColumnNames, @@ -350,28 +376,34 @@ export const createContentEditorialService = < definition.delivery.enabled && definition.delivery.slugScope === "shared"; const slugHistory = contentSlugHistoryFor({ c, definition, pluginId }); - /** Whether a base row is publicly reachable right now. `false` without publication. */ + /** + * Whether a base row is publicly reachable right now - published, live and not + * hidden. `false` without publication. + */ const publiclyVisible = (row: null | Record): boolean => { if (row === null || !publication) return false; - return isContentPubliclyVisible({ - publishedAt: row.publishedAt as Date | null | undefined, - status: typeof row.status === "string" ? row.status : undefined, - }); + return isContentBaseRowPublic(row); }; /** - * The publication state a row held *before* a transition. + * The row as it stood *before* a publish or an unpublish. * * Reconstructed rather than re-read, and it is not a guess: a transition is * guarded on the state it changes, so a `publish` that returned a row can only * have found it unpublished, and an `unpublish` can only have found it published. * A second `SELECT` would race with the writer that just won. + * + * Everything else is the returned row, unchanged - `hiddenAt` above all. A + * transition never writes it, so a hidden record was hidden before its publish + * and is hidden after it, and both sides report "not public": publishing a + * hidden record reserves no address and adds no sitemap line. */ const invert = ( operation: "publish" | "unpublish", row: Record, ): Record => ({ + ...row, publishedAt: row.publishedAt, status: operation === "publish" ? "draft" : "published", }); @@ -698,7 +730,7 @@ export const createContentEditorialService = < // coming back and its old URLs should redirect again when it does. const delivery = await applyDelivery(tx, { after: row, - before: { ...row, ...invert(operation, row) }, + before: invert(operation, row), itemId: id, }); @@ -716,6 +748,110 @@ export const createContentEditorialService = < }; }); + /** + * Hide and unhide: a version bump, a revision and - for a shared slug - the + * same delivery bookkeeping an unpublish and a publish do, all in one + * transaction. + * + * The row is locked and read first, so the before-state is the real one: the + * effects need to know whether the record was public a moment ago, and a hidden + * draft never was. The publication columns are not in the `SET` and never will + * be, so hiding cannot publish and unhiding cannot unpublish. + * + * Like a transition, it is guarded on the state it changes - which is what makes + * a repeat a harmless no-op - with `expectedVersion` checked on top when given. + */ + const visibilityTransition = async ( + id: number, + options: ContentEditorialVisibilityOptions, + action: ContentVisibilityAction, + ): Promise | null> => + await transact(options, async tx => { + const [current] = await tx + .select(ownSelection()) + .from(table) + .where(eq(primaryCursor, id)) + .limit(1) + .for("update"); + if (!current) return null; + + if ( + options.expectedVersion !== undefined && + versionOf(current) !== options.expectedVersion + ) { + throw new ContentVersionConflict({ + contentTypeId, + currentVersion: versionOf(current), + expectedVersion: options.expectedVersion, + itemId: id, + }); + } + + if (isInVisibilityState(action, current)) { + return { + changed: false, + changedFields: [], + operation: action, + previousSlug: slugOf(current), + restoredFromRevisionId: null, + revisionId: null, + row: toRow(current), + version: versionOf(current), + }; + } + + const row = await guardedWrite( + tx, + id, + versionOf(current), + visibilityValues(action, options.actor.userId), + ); + if (!row) return null; + + const version = versionOf(row); + const revisionId = await capture(tx, { + actor: options.actor, + changedFields: [], + operation: action, + row, + version, + }); + + // A hide is an unpublish as far as the address is concerned - the history + // keeps the slug, so unhiding brings its redirects back - and an unhide is a + // publish: the slug is reserved again, and refused if another record took it + // in the meantime. + const delivery = await applyDelivery(tx, { + after: row, + before: current, + itemId: id, + }); + + return { + changed: true, + changedFields: [], + ...(delivery === undefined ? {} : { delivery }), + operation: action, + previousSlug: slugOf(current), + restoredFromRevisionId: null, + revisionId, + row: toRow(row), + version, + visibility: visibilityChange({ + actorUserId: options.actor.userId, + after: row, + before: current, + }), + }; + }); + + const hideableMethods: ContentEditorialVisibilityMethods = { + hide: async (id, options) => + await visibilityTransition(id as number, options, "hide"), + unhide: async (id, options) => + await visibilityTransition(id as number, options, "unhide"), + }; + /** * Every editorial collection mutation needs an actor and an expected version. * @@ -976,7 +1112,10 @@ export const createContentEditorialService = < // Currently declared fields only. A field the content type has since // dropped is ignored; one added since is absent, so the record keeps - // what it has. + // what it has. The snapshot's `publication` and `visibility` blocks are + // never read here: restoring version 3 of a record that is hidden today + // brings back version 3's words and leaves it hidden, and the same for + // its publication status. const projected = projectRevisionSnapshot( definition, revision.snapshot, @@ -1286,5 +1425,6 @@ export const createContentEditorialService = < return { ...editorial, ...(definition.duplication.enabled ? duplication : {}), + ...(definition.visibility.enabled ? hideableMethods : {}), } as ContentEditorialService; }; diff --git a/packages/vitnode/src/content/server/index.ts b/packages/vitnode/src/content/server/index.ts index 564a8df858..25d5f4b412 100644 --- a/packages/vitnode/src/content/server/index.ts +++ b/packages/vitnode/src/content/server/index.ts @@ -7,6 +7,7 @@ export { buildSystemColumns, buildTranslationPublicationColumns, buildTranslationSystemColumns, + buildVisibilityColumns, } from "./column-builders"; export type { ColumnReferenceThunk } from "./column-builders"; export { @@ -331,4 +332,24 @@ export type { ContentTranslationSystemColumnBuilders, ContentTranslationTable, ContentTranslationTableFor, + ContentVisibilityColumnBuilders, } from "./types"; +export { + editorialVisibilityMethods, + isContentBaseRowPublic, + visibilityMethods, +} from "./visibility"; +export type { + ContentEditorialVisibilityMethods, + ContentEditorialVisibilityOptions, + ContentVisibilityChange, + ContentVisibilityMethods, + ContentVisibilityOptions, + ContentVisibilityResult, + ContentVisibilityState, +} from "./visibility"; +export { + contentVisibilityEffects, + contentVisibilityRevalidation, +} from "./visibility-effects"; +export type { ContentVisibilityEffectsResult } from "./visibility-effects"; diff --git a/packages/vitnode/src/content/server/permission-matrix.test.ts b/packages/vitnode/src/content/server/permission-matrix.test.ts index c209377940..55fee6d4d5 100644 --- a/packages/vitnode/src/content/server/permission-matrix.test.ts +++ b/packages/vitnode/src/content/server/permission-matrix.test.ts @@ -27,6 +27,8 @@ const kitchenSink = defineContentType({ tableName: "test_everything", localization: { defaultLocale: "en", fallback: "default" }, publication: true, + // So `hide` and `unhide` are audited with everything else. + visibility: true, editorial: { revisions: { retention: 10 }, preview: { expiresInMinutes: 30 }, @@ -230,6 +232,9 @@ describe("the generated permission matrix", () => { // like the plain one - it writes the same base row, plus the default // translation the engine has always required alongside it. "POST /localized": "can_create", + // Its own permission rather than `can_publish`: taking a record off the + // site is a moderation decision a site may grant on its own. + "POST /{id}/hide": "can_hide", "POST /{id}/preview": "can_view", "POST /{id}/publish": "can_publish", "POST /{id}/revisions/{revisionId}/restore": "can_restore", @@ -243,6 +248,7 @@ describe("the generated permission matrix", () => { "POST /{id}/translations/{locale}/revisions/{revisionId}/restore": "can_restore", "POST /{id}/translations/{locale}/unpublish": "can_publish", + "POST /{id}/unhide": "can_hide", "POST /{id}/unpublish": "can_publish", "PUT /{id}": "can_edit", // The composite save. One check for the shared half and every language, diff --git a/packages/vitnode/src/content/server/public-locales.ts b/packages/vitnode/src/content/server/public-locales.ts index ba34593f18..61b8e62da5 100644 --- a/packages/vitnode/src/content/server/public-locales.ts +++ b/packages/vitnode/src/content/server/public-locales.ts @@ -16,9 +16,13 @@ export type { ContentPublicLocaleState }; type PublicationRow = Record | undefined; +// `hiddenAt` is read off whichever row it is handed: the base row carries it when +// the content type has `visibility`, and a translation row never does - hiding is +// record-level, so a hidden base row takes every language down with it. const isVisible = (row: PublicationRow): boolean => row !== undefined && isContentPubliclyVisible({ + hiddenAt: row.hiddenAt as Date | null | string | undefined, publishedAt: row.publishedAt as Date | null | string | undefined, status: typeof row.status === "string" ? row.status : undefined, }); @@ -59,6 +63,9 @@ export const contentPublicLocaleStates = async < await c .get("db") .select({ + ...(definition.visibility.enabled + ? { hiddenAt: columns.hiddenAt } + : {}), publishedAt: columns.publishedAt, status: columns.status, ...(slugIsLocalized ? {} : { [slugField]: columns[slugField] }), diff --git a/packages/vitnode/src/content/server/publication.test.ts b/packages/vitnode/src/content/server/publication.test.ts index 3495799b08..675930f16b 100644 --- a/packages/vitnode/src/content/server/publication.test.ts +++ b/packages/vitnode/src/content/server/publication.test.ts @@ -5,6 +5,7 @@ import { describe, expect, it } from "vitest"; import { testCategoryContentType, + testHideableNoteContentType, testPostContentType, } from "@/tests/content-fixtures"; @@ -21,6 +22,8 @@ const posts = createContentModel(testPostContentType, { references: { category: () => categories.table.id }, }); +const notes = createContentModel(testHideableNoteContentType); + const dialect = new PgDialect(); /** The SQL text and bound parameters Drizzle would actually send. */ @@ -43,6 +46,24 @@ describe("publishedCondition", () => { expect(params).toEqual(["published"]); }); + it("adds `hiddenAt IS NULL` for a content type with visibility", () => { + const { sql } = compile( + publishedCondition( + publicationColumns(testHideableNoteContentType, notes.columns), + ), + ); + + expect(sql).toContain('("test_hideable_notes"."hiddenAt" is null)'); + }); + + it("refuses a visibility column map that lost `hiddenAt`", () => { + const { hiddenAt: _dropped, ...rest } = notes.columns; + + expect(() => publicationColumns(testHideableNoteContentType, rest)).toThrow( + ContentEngineError, + ); + }); + it("binds the status rather than inlining it", () => { expect(compile(publishedCondition(posts.columns)).sql).not.toContain( "'published'", diff --git a/packages/vitnode/src/content/server/publication.ts b/packages/vitnode/src/content/server/publication.ts index f36bf358cc..f4b7d3169d 100644 --- a/packages/vitnode/src/content/server/publication.ts +++ b/packages/vitnode/src/content/server/publication.ts @@ -1,7 +1,7 @@ import type { SQL } from "drizzle-orm"; import type { PgColumn } from "drizzle-orm/pg-core"; -import { and, eq, isNotNull, lte, sql } from "drizzle-orm"; +import { and, eq, isNotNull, isNull, lte, sql } from "drizzle-orm"; import type { AnyContentTypeDefinition } from "../types"; import type { ContentPublicationMethods, ContentService } from "./service"; @@ -9,6 +9,12 @@ import type { ContentPublicationMethods, ContentService } from "./service"; import { ContentEngineError } from "../errors"; export interface PublicationColumns { + /** + * The base row's `hiddenAt`, present only when the content type has + * `visibility` enabled. A translation table has none: hiding is record-level, + * so it is always the *base* predicate that carries it. + */ + hiddenAt?: PgColumn; publishedAt: PgColumn; status: PgColumn; } @@ -17,7 +23,7 @@ export const publicationColumns = ( definition: AnyContentTypeDefinition, columns: Record, ): PublicationColumns => { - const { publishedAt, status } = columns; + const { hiddenAt, publishedAt, status } = columns; if (!definition.publication.enabled || !publishedAt || !status) { throw new ContentEngineError( @@ -26,9 +32,25 @@ export const publicationColumns = ( ); } - return { publishedAt, status }; + if (!definition.visibility.enabled) return { publishedAt, status }; + + // Thrown rather than skipped: a visibility-enabled content type read without + // its `hiddenAt` column would quietly serve every hidden record. + if (!hiddenAt) { + throw new ContentEngineError( + "`visibility` is enabled, but the column map has no `hiddenAt`. Build the columns with `contentTableColumns`.", + { contentTypeId: definition.id }, + ); + } + + return { hiddenAt, publishedAt, status }; }; +/** + * The one SQL statement of "publicly reachable": published, already live, and - + * for a content type with `visibility` - not hidden. Every public read builds + * its `WHERE` from this, so a hidden record disappears everywhere at once. + */ export const publishedCondition = ( columns: PublicationColumns, ): SQL | undefined => @@ -36,6 +58,7 @@ export const publishedCondition = ( eq(columns.status, "published"), isNotNull(columns.publishedAt), lte(columns.publishedAt, sql`now()`), + columns.hiddenAt === undefined ? undefined : isNull(columns.hiddenAt), ); export const contentTranslationPublicationColumns = ( diff --git a/packages/vitnode/src/content/server/query.test.ts b/packages/vitnode/src/content/server/query.test.ts index 8ac66da16f..e8f1c293cf 100644 --- a/packages/vitnode/src/content/server/query.test.ts +++ b/packages/vitnode/src/content/server/query.test.ts @@ -8,6 +8,7 @@ import { field } from "@/content/fields"; import { testArticleContentType, testCategoryContentType, + testHideableNoteContentType, testPostContentType, } from "@/tests/content-fixtures"; @@ -285,6 +286,60 @@ describe("buildFilterCondition", () => { "archived", ]); }); + }); + + describe("visibility", () => { + const hideableColumns = contentTableColumns( + testHideableNoteContentType, + createContentTable(testHideableNoteContentType), + ); + const visibilityFilter = ( + filters: Record, + visibility = true, + ) => + buildFilterCondition({ + columns: hideableColumns, + contentTypeId: testHideableNoteContentType.id, + fields: testHideableNoteContentType.fields, + filters, + publication: true, + visibility, + }); + + it("reads `hidden` as hiddenAt IS NOT NULL and `visible` as IS NULL", () => { + expect(compile(visibilityFilter({ visibility: "hidden" })).sql).toBe( + '("test_hideable_notes"."hiddenAt" is not null)', + ); + expect(compile(visibilityFilter({ visibility: "visible" })).sql).toBe( + '("test_hideable_notes"."hiddenAt" is null)', + ); + }); + + it("composes with the status filter", () => { + const { params, sql } = compile( + visibilityFilter({ status: "published", visibility: "hidden" }), + ); + + expect(sql).toBe( + '(("test_hideable_notes"."status" = $1) and (("test_hideable_notes"."hiddenAt" is not null)))', + ); + expect(params).toEqual(["published"]); + }); + + it.each([["secret"], [""], [null], [true]])( + "rejects %j before it reaches SQL", + value => { + expect(() => visibilityFilter({ visibility: value })).toThrow( + /Invalid visibility filter/, + ); + }, + ); + + it("is an unknown filter for a content type without visibility", () => { + expect(() => visibilityFilter({ visibility: "hidden" }, false)).toThrow( + /Unknown filter "visibility"/, + ); + }); it("does not accept a status filter when publication is off", () => { expect(() => diff --git a/packages/vitnode/src/content/server/query.ts b/packages/vitnode/src/content/server/query.ts index d4c2df8a13..e9337adbfd 100644 --- a/packages/vitnode/src/content/server/query.ts +++ b/packages/vitnode/src/content/server/query.ts @@ -1,7 +1,7 @@ import type { SQL } from "drizzle-orm"; import type { PgColumn } from "drizzle-orm/pg-core"; -import { and, eq, ilike, isNull, or, sql } from "drizzle-orm"; +import { and, eq, ilike, isNotNull, isNull, or, sql } from "drizzle-orm"; import type { ContentFieldDescriptor, @@ -13,6 +13,7 @@ import { containsLikePattern } from "../../lib/api/like-pattern"; import { CONTENT_FILTERABLE_FIELD_KINDS, CONTENT_PUBLICATION_STATUSES, + CONTENT_VISIBILITY_FILTERS, isContentPublicationStatus, isFilterableFieldKind, } from "../const"; @@ -81,6 +82,7 @@ export const buildFilterCondition = ({ filters, membership, publication = false, + visibility = false, }: { /** * Narrows the filterable set further, for a caller with its own allowlist - @@ -106,6 +108,12 @@ export const buildFilterCondition = ({ ) => SQL | undefined; /** Whether `status` is a generated column and therefore filterable. */ publication?: boolean; + /** + * Whether `visibility` is a filter: `hidden` or `visible`, against the + * generated `hiddenAt`. Never passed by the public services - a public reader + * cannot ask for hidden records, and their allowlist refuses the key anyway. + */ + visibility?: boolean; }): SQL | undefined => { const conditions: SQL[] = []; @@ -136,6 +144,25 @@ export const buildFilterCondition = ({ continue; } + // Composes with `status` like any other equality: "published but hidden" is + // `status=published&visibility=hidden`, and is exactly the set of records a + // reader cannot reach although the AdminCP calls them published. + if (visibility && name === "visibility" && columns.hiddenAt) { + if (raw !== "hidden" && raw !== "visible") { + throw new ContentEngineError( + `Invalid visibility filter ${JSON.stringify(raw)}. Allowed values: ${CONTENT_VISIBILITY_FILTERS.join(", ")}.`, + { contentTypeId }, + ); + } + + conditions.push( + raw === "hidden" + ? isNotNull(columns.hiddenAt) + : isNull(columns.hiddenAt), + ); + continue; + } + const fieldValue = fields[name]; // A to-many relation has no column, so it is answered before the column diff --git a/packages/vitnode/src/content/server/revision-snapshot.ts b/packages/vitnode/src/content/server/revision-snapshot.ts index 02aa6e8efc..1fd4ae64a6 100644 --- a/packages/vitnode/src/content/server/revision-snapshot.ts +++ b/packages/vitnode/src/content/server/revision-snapshot.ts @@ -133,6 +133,13 @@ export const contentRevisionSnapshot = ( }; } + if (definition.visibility.enabled) { + snapshot.visibility = { + hiddenAt: toIsoOrNull(values.hiddenAt), + hiddenBy: typeof values.hiddenBy === "number" ? values.hiddenBy : null, + }; + } + return snapshot; }; diff --git a/packages/vitnode/src/content/server/route-helpers.ts b/packages/vitnode/src/content/server/route-helpers.ts index ab91cb4e58..1bacde6260 100644 --- a/packages/vitnode/src/content/server/route-helpers.ts +++ b/packages/vitnode/src/content/server/route-helpers.ts @@ -31,6 +31,23 @@ export const readJson = async ( schema: z.ZodType, ): Promise => schema.parse(await c.req.json()); +/** + * {@link readJson} for a body the client may leave out entirely - `POST + * /{id}/hide` with nothing to say is the same as `{}`. A body that *is* sent is + * validated exactly as strictly as any other. + */ +export const readOptionalJson = async ( + c: Context, + schema: z.ZodType, +): Promise => { + const contentType = c.req.header("content-type") ?? ""; + if (!contentType.toLowerCase().includes("json")) return schema.parse({}); + + const text = await c.req.text(); + + return schema.parse(text.trim() === "" ? {} : JSON.parse(text)); +}; + export const jsonBody = (schema: z.ZodType) => ({ content: { "application/json": { schema } }, }); diff --git a/packages/vitnode/src/content/server/routes.ts b/packages/vitnode/src/content/server/routes.ts index f759250d21..783ab78955 100644 --- a/packages/vitnode/src/content/server/routes.ts +++ b/packages/vitnode/src/content/server/routes.ts @@ -4,6 +4,7 @@ import { z } from "@hono/zod-openapi"; import { HTTPException } from "hono/http-exception"; import type { StorageFileUploadResult } from "../../api/models/storage"; +import type { ContentIdOf } from "../ids"; import type { AnyContentTypeDefinition, ContentFilterInput, @@ -14,8 +15,10 @@ import type { ContentDuplicationMethods, ContentEditorialDuplicationMethods, } from "./duplicate"; +import type { ContentEditorialOutcome } from "./editorial-service"; import type { AnyContentModel, ContentModel } from "./model"; import type { ContentPreviewTarget } from "./preview-target"; +import type { ContentVisibilityAction } from "./visibility"; import { checkStaffPermission } from "../../api/lib/check-staff-permission"; import { buildRoute } from "../../api/lib/route"; @@ -85,9 +88,17 @@ import { resolveContentPreviewTarget } from "./preview-target"; import { createContentPreviewToken } from "./preview-token"; import { publicationMethods } from "./publication"; import { CONTENT_REVISIONS_MAX_PAGE_SIZE } from "./revisions-model"; -import { identifier, jsonBody, jsonResponse, readJson } from "./route-helpers"; +import { + identifier, + jsonBody, + jsonResponse, + readJson, + readOptionalJson, +} from "./route-helpers"; import { contentSearchAdvancedValues, syncContentSearch } from "./search-sync"; import { buildContentTranslationRoutes } from "./translation-routes"; +import { editorialVisibilityMethods, visibilityMethods } from "./visibility"; +import { contentVisibilityEffects } from "./visibility-effects"; const zodLabels = z.record(z.string(), z.string().nullable()); @@ -1242,6 +1253,119 @@ export const buildContentRoutes = < }, }); + /** + * `{ expectedVersion? }`, and the whole body optional: hiding overwrites no + * field value, so - like publishing - it is guarded on the state it changes, + * and the version is checked only when the client chose to send one. A content + * type without `editorial` has no version, and ignores it. + */ + const visibilityBody = z.strictObject({ + expectedVersion: z.number().int().positive().optional(), + }); + + /** + * `POST /{id}/hide` and `POST /{id}/unhide`. + * + * Their own permission, `can_hide`, rather than `can_publish`: taking a record + * off the site without unpublishing it is a moderation decision, and a site may + * want moderators who can do that without being able to publish anything. + * Idempotent like publishing - hiding a hidden record is a 200 that changed + * nothing, wrote no revision and announced nothing. + */ + const visibilityRoute = (action: ContentVisibilityAction) => + buildRoute({ + pluginId, + adminStaffPermission: { module, permission: CONTENT_PERMISSIONS.hide }, + route: { + method: "post", + path: `/{id}/${action}` as const, + description: + action === "hide" + ? `Hide a ${name} from the public site, whatever its publication status` + : `Make a hidden ${name} publicly available again`, + request: { + params: schemas.params, + body: { ...jsonBody(visibilityBody), required: false }, + }, + responses: { + 200: jsonResponse( + publicationResponse, + action === "hide" + ? `${name} hidden, or already hidden` + : `${name} visible again, or already visible`, + ), + 400: invalidIdentifier, + 404: { description: `${name} not found` }, + ...(editorial + ? { + 409: jsonResponse( + conflictSchema, + definition.delivery.redirects.enabled + ? "The version moved, or another record took the address meanwhile" + : "The version moved", + ), + } + : {}), + }, + }, + handler: async c => { + const id = identifier(c); + // Serial until the ID strategies land, and `identifier` has already + // proven it a positive integer. + const contentId = id as ContentIdOf; + const { expectedVersion } = await readOptionalJson(c, visibilityBody); + const actor = resolveContentActor(c); + + let outcome: ContentEditorialOutcome; + + if (editorial) { + const result = await withHttpErrors( + "update", + async () => + await editorialVisibilityMethods(definition, editorialService(c))[ + action + ](contentId, { actor, expectedVersion }), + { contentTypeId: definition.id, itemId: id, structured: true }, + ); + if (!result) throw notFound(definition); + + outcome = result; + } else { + const result = await withHttpErrors( + "update", + async () => + await visibilityMethods(definition, model.service(c))[action]( + contentId, + { actorUserId: actor.userId }, + ), + ); + if (!result) throw notFound(definition); + + // The editorial outcome's shape with no history behind it - the same + // thing the scheduled path builds - so both services share one set of + // effects, and a listener cannot tell which one hid the record. + outcome = { + changed: result.changed, + changedFields: [], + operation: action, + previousSlug: null, + restoredFromRevisionId: null, + revisionId: null, + row: result.row, + version: 0, + visibility: result.visibility, + }; + } + + // After the commit: the event, the search document turning private or + // public, the delivery events, and the front ends' caches. A no-op + // touches none of them. + await contentVisibilityEffects(c, model, outcome, { pluginId }); + + return c.json({ changed: outcome.changed, row: outcome.row }, 200); + }, + }); + const zodRevisionMeta = z.object({ actorName: z.string().nullable(), actorRoleColor: z.string().nullable(), @@ -1935,6 +2059,11 @@ export const buildContentRoutes = < // Mounted only when the content type opts in, so no other content type gains // an endpoint - or a permission row in the matrix - it does not use. ...(definition.duplication.enabled ? [duplicate] : []), + // Mounted only with `visibility`, so every other content type's route list - + // and its permission matrix - is unchanged. + ...(definition.visibility.enabled + ? [visibilityRoute("hide"), visibilityRoute("unhide")] + : []), ...(editorial ? [revisionList, revisionDetail, restore] : []), ...(definition.liveEditing.enabled ? buildContentLiveRoutes(model, { pluginId }) diff --git a/packages/vitnode/src/content/server/schedule-effects.ts b/packages/vitnode/src/content/server/schedule-effects.ts index 63cabe47eb..20b1d2ef07 100644 --- a/packages/vitnode/src/content/server/schedule-effects.ts +++ b/packages/vitnode/src/content/server/schedule-effects.ts @@ -75,6 +75,7 @@ const reviveDates = ( "createdAt", "updatedAt", ...(definition.publication.enabled ? ["publishedAt"] : []), + ...(definition.visibility.enabled ? ["hiddenAt"] : []), ...Object.entries(definition.fields) .filter(([, field]) => field.kind === "dateTime") .map(([name]) => name), @@ -160,17 +161,26 @@ export const runContentScheduleEffects = async ( ? row[definition.publicApi.slugField] : undefined; + const isPublic = isContentRowPublic(row); + const revalidation = await dispatchContentRevalidation(c, { contentTypeId: definition.id, - // A scheduled transition always flips public reachability, so it changes both the - // file it adds a line to (or removes one from) and the index that counts them. - // Absent for a content type without `delivery`, which keeps its tag list - // byte-identical. + // A scheduled transition flips public reachability - unless the record is + // hidden, in which case it was off the site before and stays off it after, and + // neither the sitemap file nor its index moved. Absent for a content type + // without `delivery`, which keeps its tag list byte-identical. ...(definition.delivery.enabled - ? { delivery: { sitemap: { contentChanged: true, indexChanged: true } } } + ? { + delivery: { + sitemap: { + contentChanged: payload.wasPublic || isPublic, + indexChanged: payload.wasPublic !== isPublic, + }, + }, + } : {}), id: payload.itemId, - isPublic: isContentRowPublic(row), + isPublic, // A scheduled transition moves the *record*, and the record's publication // state gates every language - so every locale that had a page, or has one // now, is expired. Absent for a content type that is not localized, which diff --git a/packages/vitnode/src/content/server/search-document.ts b/packages/vitnode/src/content/server/search-document.ts index cc98bc4cbe..ff9d4dbdd7 100644 --- a/packages/vitnode/src/content/server/search-document.ts +++ b/packages/vitnode/src/content/server/search-document.ts @@ -58,6 +58,7 @@ export const isContentRowPublic = (row: object): boolean => { const values = row as Record; return isContentPubliclyVisible({ + hiddenAt: toTimestamp(values.hiddenAt), publishedAt: toTimestamp(values.publishedAt), status: normalize(values.status) || undefined, }); @@ -175,6 +176,7 @@ export const contentTranslationSearchDocument = ( const isPublic = isContentTranslationPubliclyVisible({ base: { + hiddenAt: toTimestamp(baseValues.hiddenAt), publishedAt: toTimestamp(baseValues.publishedAt), status: normalize(baseValues.status) || undefined, }, diff --git a/packages/vitnode/src/content/server/search-indexer.ts b/packages/vitnode/src/content/server/search-indexer.ts index a82f67d3a3..02f7fb1189 100644 --- a/packages/vitnode/src/content/server/search-indexer.ts +++ b/packages/vitnode/src/content/server/search-indexer.ts @@ -41,6 +41,16 @@ const REQUIRED_COLUMNS = [ "publishedAt", ] as const; +/** + * {@link REQUIRED_COLUMNS}, plus `hiddenAt` for a content type with `visibility`: + * a rebuild has to index a hidden record as private, exactly as the live hide + * did, or every rebuild would put it back into public results. + */ +const requiredColumns = (definition: AnyContentTypeDefinition): string[] => [ + ...REQUIRED_COLUMNS, + ...(definition.visibility.enabled ? ["hiddenAt"] : []), +]; + interface ContentSearchSources { /** Collection fields to batch-load for the page's parent ids. */ collections: string[]; @@ -165,10 +175,9 @@ export const createContentSearchIndexer = < const sources = resolveSearchSources(definition); const advanced = model.advanced; const selection: Record = Object.fromEntries( - [...new Set([...REQUIRED_COLUMNS, ...sources.sharedColumns])].map(name => [ - name, - columns[name], - ]), + [ + ...new Set([...requiredColumns(definition), ...sources.sharedColumns]), + ].map(name => [name, columns[name]]), ); const cursors = new WeakMap(); @@ -259,7 +268,7 @@ export const createContentLocalizedSearchIndexer = < const sources = resolveSearchSources(definition); const advanced = model.advanced; const sharedSelection = [ - ...new Set([...REQUIRED_COLUMNS, ...sources.sharedColumns]), + ...new Set([...requiredColumns(definition), ...sources.sharedColumns]), ]; const localizedSelection = [...new Set(sources.localizedColumns)]; diff --git a/packages/vitnode/src/content/server/search-sync.test.ts b/packages/vitnode/src/content/server/search-sync.test.ts index 59dcd892fc..48081e8530 100644 --- a/packages/vitnode/src/content/server/search-sync.test.ts +++ b/packages/vitnode/src/content/server/search-sync.test.ts @@ -6,6 +6,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { createTestCache } from "@/tests/cache"; import { testCategoryContentType, + testHideablePostContentType, testPostContentType, testSearchablePostContentType, } from "@/tests/content-fixtures"; @@ -16,6 +17,7 @@ import { import { createContentModel } from "./model"; import { buildContentRoutes } from "./routes"; +import { syncContentSearch } from "./search-sync"; const categories = createContentModel(testCategoryContentType); const searchable = createContentModel(testSearchablePostContentType); @@ -502,3 +504,75 @@ describe("content search lifecycle synchronization", () => { }); }); }); + +describe("hide and unhide", () => { + const searchOf = () => { + const search = { delete: vi.fn(), index: vi.fn() }; + const c = { + get: (key: string) => (key === "search" ? search : undefined), + } as unknown as Context; + + return { c, search }; + }; + + const row = { + createdAt: CREATED_AT, + excerpt: "Excerpt.", + hiddenAt: null as Date | null, + hiddenBy: null, + id: 9, + publishedAt: PUBLISHED_AT, + slug: "hidden-world", + status: "published" as const, + title: "Hidden world", + updatedAt: CREATED_AT, + version: 3, + }; + + it("rewrites a hidden record's document as private, with no URL", async () => { + const { c, search } = searchOf(); + + const outcome = await syncContentSearch(c, testHideablePostContentType, { + changed: true, + operation: "hide", + row: { ...row, hiddenAt: new Date("2026-03-01T00:00:00.000Z") }, + }); + + expect(outcome.action).toBe("upsert"); + expect(search.index).toHaveBeenCalledWith( + expect.objectContaining({ isPublic: false, itemId: 9 }), + ); + expect(search.index.mock.calls[0][0]).not.toHaveProperty("url"); + }); + + it("makes it public again on unhide", async () => { + const { c, search } = searchOf(); + + await syncContentSearch(c, testHideablePostContentType, { + changed: true, + operation: "unhide", + row, + }); + + expect(search.index).toHaveBeenCalledWith( + expect.objectContaining({ + isPublic: true, + url: "/hideable-posts/hidden-world", + }), + ); + }); + + it("touches nothing for a repeat", async () => { + const { c, search } = searchOf(); + + const outcome = await syncContentSearch(c, testHideablePostContentType, { + changed: false, + operation: "hide", + row, + }); + + expect(outcome.action).toBe("skip"); + expect(search.index).not.toHaveBeenCalled(); + expect(search.delete).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/vitnode/src/content/server/search-sync.ts b/packages/vitnode/src/content/server/search-sync.ts index fbc14d2d6c..af12678a0b 100644 --- a/packages/vitnode/src/content/server/search-sync.ts +++ b/packages/vitnode/src/content/server/search-sync.ts @@ -25,11 +25,23 @@ import { export type ContentSearchOperation = | "create" | "delete" + | "hide" | "publish" | "restore" + | "unhide" | "unpublish" | "update"; +/** + * The mutations that move public reachability without moving a field: the + * document is rewritten - public or private - exactly when the row moved. + */ +const isReachabilityOperation = (operation: ContentSearchOperation): boolean => + operation === "publish" || + operation === "unpublish" || + operation === "hide" || + operation === "unhide"; + export interface ContentSearchSyncInput { advanced?: Record; @@ -62,8 +74,9 @@ const decide = ( if (operation === "delete") return "delete"; // An idempotent transition is a no-op for the same reason it emits no event: - // the document is already there and would be rewritten byte for byte. - if (operation === "publish" || operation === "unpublish") { + // the document is already there and would be rewritten byte for byte. A hide + // and an unhide are transitions too: the document stays, as a private one. + if (isReachabilityOperation(operation)) { return changed === true ? "upsert" : "skip"; } @@ -300,10 +313,7 @@ export const syncContentLocalizedSearch = async ( // An idempotent transition is a no-op for the same reason it emits no event: // every document is already exactly what it would be rewritten to. - if ( - (input.operation === "publish" || input.operation === "unpublish") && - input.changed !== true - ) { + if (isReachabilityOperation(input.operation) && input.changed !== true) { return []; } diff --git a/packages/vitnode/src/content/server/service.ts b/packages/vitnode/src/content/server/service.ts index 6031ff64be..02d577638d 100644 --- a/packages/vitnode/src/content/server/service.ts +++ b/packages/vitnode/src/content/server/service.ts @@ -34,6 +34,13 @@ import type { ContentAdvancedStore } from "./advanced-store"; import type { ContentDuplicationMethods } from "./duplicate"; import type { ContentPickerTarget } from "./references"; import type { ContentTranslationModel } from "./translation-model"; +import type { + ContentVisibilityAction, + ContentVisibilityMembers, + ContentVisibilityMethods, + ContentVisibilityOptions, + ContentVisibilityResult, +} from "./visibility"; import { withPagination } from "../../api/lib/with-pagination"; import { @@ -42,6 +49,7 @@ import { CONTENT_OPTIONS_LIMIT, CONTENT_PUBLICATION_FIELDS, CONTENT_SYSTEM_FIELDS, + CONTENT_VISIBILITY_FIELDS, } from "../const"; import { ContentEngineError } from "../errors"; import { partitionContentFields } from "../localization"; @@ -73,6 +81,12 @@ import { toLabel, } from "./references"; import { createSlugNormalizer } from "./slugs"; +import { + isInVisibilityState, + visibilityChange, + visibilityStateOf, + visibilityValues, +} from "./visibility"; /** Display labels for `user` and `relation` values, keyed by field name. */ export type ContentLabels = Record; @@ -225,6 +239,7 @@ export interface ContentPublicationMethods { } export type ContentService = ContentServiceBase & + ContentVisibilityMembers> & (TDefinition extends { duplication: { enabled: true } } ? ContentDuplicationMethods : Partial, never>>) & @@ -346,6 +361,7 @@ export const createContentService = < ...CONTENT_SYSTEM_FIELDS, ...(publication ? CONTENT_PUBLICATION_FIELDS : []), ...(definition.editorial.enabled ? CONTENT_EDITORIAL_FIELDS : []), + ...(definition.visibility.enabled ? CONTENT_VISIBILITY_FIELDS : []), ]; const ownColumnNames = [ ...generatedColumnNames, @@ -639,6 +655,71 @@ export const createContentService = < ), }; + /** + * Hides or unhides one record. + * + * Read under `FOR UPDATE` first, so the before-state the effects need - was it + * public a moment ago? - is the row as it really was rather than a guess, and + * two concurrent hides cannot both stamp `hiddenAt`. `status` and `publishedAt` + * are never in the `SET`: hiding is a separate axis from publication. + */ + const visibilityTransition = async ( + id: number, + action: ContentVisibilityAction, + options: ContentVisibilityOptions | undefined, + ): Promise | null> => + await inTransaction(options, async tx => { + const [current] = await tx + .select(ownSelection()) + .from(table) + .where(eq(primaryCursor, id)) + .limit(1) + .for("update"); + if (!current) return null; + + if (isInVisibilityState(action, current)) { + const state = visibilityStateOf(current); + + return { + changed: false, + ...state, + row: toRow(current), + visibility: visibilityChange({ + actorUserId: options?.actorUserId, + after: current, + before: current, + }), + }; + } + + const [row] = await tx + .update(table) + .set(visibilityValues(action, options?.actorUserId)) + .where(eq(primaryCursor, id)) + .returning(ownSelection()); + if (!row) return null; + + return { + changed: true, + ...visibilityStateOf(row), + row: toRow(row), + visibility: visibilityChange({ + actorUserId: options?.actorUserId, + after: row, + before: current, + }), + }; + }); + + // Serial today, so the identifier is a number at runtime; `ContentIdOf` keeps + // the signature honest for the strategies that are not. + const hideableMethods: ContentVisibilityMethods = { + hide: async (id, options) => + await visibilityTransition(id as number, "hide", options), + unhide: async (id, options) => + await visibilityTransition(id as number, "unhide", options), + }; + /** * Runs `body` in the caller's transaction, or in one opened for it. * @@ -805,6 +886,7 @@ export const createContentService = < filters: filters, membership: store?.membershipCondition, publication, + visibility: definition.visibility.enabled, }), searchCondition(query.search), ].filter((item): item is SQL => item !== undefined); @@ -1206,5 +1288,6 @@ export const createContentService = < ...service, ...(publication ? publicationMethods : {}), ...(definition.duplication.enabled ? duplicationMethods : {}), + ...(definition.visibility.enabled ? hideableMethods : {}), } as ContentService; }; diff --git a/packages/vitnode/src/content/server/table.ts b/packages/vitnode/src/content/server/table.ts index 7934765c7a..4022825111 100644 --- a/packages/vitnode/src/content/server/table.ts +++ b/packages/vitnode/src/content/server/table.ts @@ -26,7 +26,11 @@ import type { import { core_files } from "../../database/files"; import { core_users } from "../../database/users"; -import { CONTENT_EDITORIAL_FIELDS, CONTENT_PUBLICATION_FIELDS } from "../const"; +import { + CONTENT_EDITORIAL_FIELDS, + CONTENT_PUBLICATION_FIELDS, + CONTENT_VISIBILITY_FIELDS, +} from "../const"; import { ContentEngineError } from "../errors"; import { partitionContentFields } from "../localization"; import { contentStorageColumns } from "../paths"; @@ -35,6 +39,7 @@ import { buildEditorialColumns, buildPublicationColumns, buildSystemColumns, + buildVisibilityColumns, } from "./column-builders"; const checkedReference = ( @@ -148,6 +153,9 @@ export const createContentTable = < ...buildSystemColumns(), ...(definition.publication.enabled ? buildPublicationColumns() : {}), ...(definition.editorial.enabled ? buildEditorialColumns() : {}), + ...(definition.visibility.enabled + ? buildVisibilityColumns(() => core_users.id) + : {}), }; for (const name of Object.keys(fields)) { @@ -216,6 +224,7 @@ export const contentTableColumns = < "updatedAt", ...(definition.publication.enabled ? CONTENT_PUBLICATION_FIELDS : []), ...(definition.editorial.enabled ? CONTENT_EDITORIAL_FIELDS : []), + ...(definition.visibility.enabled ? CONTENT_VISIBILITY_FIELDS : []), ...Object.keys(contentStorageColumns(sharedFields)), ]; diff --git a/packages/vitnode/src/content/server/translation-model.ts b/packages/vitnode/src/content/server/translation-model.ts index b9efd32032..5a01c8a227 100644 --- a/packages/vitnode/src/content/server/translation-model.ts +++ b/packages/vitnode/src/content/server/translation-model.ts @@ -98,10 +98,18 @@ export interface ContentTranslationModel { options?: ContentTranslationOptions, ) => Promise; + /** + * The base row's publication state - and its `hiddenAt` when the content type + * has `visibility`, because a hidden record is public in no language. + */ findBasePublication: ( itemId: number, options?: ContentTranslationOptions, - ) => Promise; + ) => Promise; findByLanguageId: ( itemId: number, languageId: number, @@ -526,10 +534,12 @@ export const createContentTranslationModel = < findBasePublication: async (itemId, options) => { const baseColumns = table as unknown as Record; + const hideable = definition.visibility.enabled; const [row] = await db(options) .select( publication ? { + ...(hideable ? { hiddenAt: baseColumns.hiddenAt } : {}), publishedAt: baseColumns.publishedAt, status: baseColumns.status, } @@ -543,6 +553,13 @@ export const createContentTranslationModel = < if (!publication) return { publishedAt: null, status: undefined }; return { + ...(hideable + ? { + hiddenAt: toNullableDate( + (row as Record).hiddenAt, + ), + } + : {}), publishedAt: toNullableDate(row.publishedAt), status: typeof row.status === "string" ? row.status : undefined, }; diff --git a/packages/vitnode/src/content/server/types.ts b/packages/vitnode/src/content/server/types.ts index 8196ed6d30..0f3c9658bc 100644 --- a/packages/vitnode/src/content/server/types.ts +++ b/packages/vitnode/src/content/server/types.ts @@ -25,6 +25,7 @@ import type { ContentSharedFieldName, ContentSystemField, ContentTranslationSystemField, + ContentVisibilityField, HasColumnDefault, } from "../types"; @@ -98,13 +99,26 @@ type EditorialColumnBuilders = ? ContentEditorialColumnBuilders : Record; +/** `hiddenAt` and `hiddenBy` - added only when visibility is enabled. */ +export interface ContentVisibilityColumnBuilders { + hiddenAt: PgTimestampBuilder; + hiddenBy: PgIntegerBuilder; +} + +type VisibilityColumnBuilders = + TVisibility extends true + ? ContentVisibilityColumnBuilders + : Record; + export type ContentColumnBuilders< TFields, TPublication extends boolean = false, TEditorial extends boolean = false, + TVisibility extends boolean = false, > = ContentSystemColumnBuilders & EditorialColumnBuilders & - PublicationColumnBuilders & { + PublicationColumnBuilders & + VisibilityColumnBuilders & { [K in keyof TFields]: ContentColumnBuilder; }; @@ -168,10 +182,11 @@ export type ContentTable< TFields, TPublication extends boolean = false, TEditorial extends boolean = false, + TVisibility extends boolean = false, > = PgTableWithColumns<{ columns: PgBuildColumns< TName, - ContentColumnBuilders + ContentColumnBuilders >; dialect: "pg"; name: TName; @@ -249,7 +264,11 @@ export type ContentTableFor = TDefinition extends { TName, ContentStorageFields>, TPublication, - TEditorial + TEditorial, + // Read on its own rather than inferred beside the other two: a definition + // written before `visibility` existed has no such key, and it must keep + // resolving to the table it always had. + TDefinition extends { visibility: { enabled: true } } ? true : false > : never; @@ -273,6 +292,9 @@ export type ContentColumnName = : never) | (TDefinition extends { publication: { enabled: true } } ? ContentPublicationField + : never) + | (TDefinition extends { visibility: { enabled: true } } + ? ContentVisibilityField : never); /** diff --git a/packages/vitnode/src/content/server/visibility-effects.ts b/packages/vitnode/src/content/server/visibility-effects.ts new file mode 100644 index 0000000000..d81c7d0d91 --- /dev/null +++ b/packages/vitnode/src/content/server/visibility-effects.ts @@ -0,0 +1,136 @@ +import type { Context } from "hono"; + +import type { AnyContentTypeDefinition } from "../types"; +import type { + ContentEditorialEffectsOptions, + ContentEditorialEffectsResult, +} from "./editorial-effects"; +import type { ContentEditorialOutcome } from "./editorial-service"; +import type { AnyContentModel } from "./model"; +import type { ContentRevalidationRequest } from "./revalidate-bridge"; +import type { ContentVisibilityChange } from "./visibility"; + +import { + contentLocaleInvalidations, + diffContentPublicLocaleStates, +} from "../cache"; +import { contentEditorialEffects } from "./editorial-effects"; +import { contentDefinitionOf } from "./model"; +import { contentPublicLocaleStates } from "./public-locales"; +import { dispatchContentRevalidation } from "./revalidate-bridge"; + +export interface ContentVisibilityEffectsResult extends ContentEditorialEffectsResult { + /** The front-end cache notification. `attempted: 0` means nothing to tell. */ + revalidation: { attempted: number; delivered: number }; +} + +const NOTHING: ContentVisibilityEffectsResult = { + event: null, + revalidation: { attempted: 0, delivered: 0 }, + search: null, +}; + +/** + * What a hide or an unhide expires, in the shape the revalidation bridge takes. + * + * Built from the reachability the service measured on both sides of its write, + * never from the operation's name: hiding a draft expires nothing, because there + * was no public page to take down. + */ +export const contentVisibilityRevalidation = async ( + c: Context, + model: AnyContentModel, + { + row, + visibility, + }: { + row: Record; + visibility: ContentVisibilityChange; + }, +): Promise => { + const definition: AnyContentTypeDefinition = contentDefinitionOf(model); + const { isPublic, wasPublic } = visibility; + const id = typeof row.id === "number" ? row.id : 0; + const slug = definition.publicApi.enabled + ? row[definition.publicApi.slugField] + : undefined; + + // A hidden record is hidden in every language, so each locale that had a page + // - its own or the fallback's - or has one now is expired. The before-state is + // the same row with the visibility columns it held a moment ago. + const locales = + definition.localization.enabled && definition.publicApi.enabled + ? contentLocaleInvalidations({ + changed: "shared", + defaultLocale: definition.localization.defaultLocale, + fallback: definition.localization.fallback, + states: diffContentPublicLocaleStates( + await contentPublicLocaleStates(c, model, id, { + row: { ...row, ...visibility.before }, + }), + await contentPublicLocaleStates(c, model, id, { row }), + ), + }) + : undefined; + + return { + contentTypeId: definition.id, + // Exactly what `applyContentDeliveryWrite` reports for the same move: the file + // changed when the record was or is listed in it, and the index only when it + // appeared or disappeared. + ...(definition.delivery.enabled + ? { + delivery: { + sitemap: { + contentChanged: wasPublic || isPublic, + indexChanged: wasPublic !== isPublic, + }, + }, + } + : {}), + id, + isPublic, + ...(locales === undefined ? {} : { locales }), + // A hidden page must stop being served now, not after its next revalidation. + mode: "immediate", + slugs: typeof slug === "string" && slug !== "" ? [slug] : [], + wasPublic, + }; +}; + +/** + * Everything a committed hide or unhide announces, after the transaction. + * + * The event, the delivery events and the search document go through + * `contentEditorialEffects` - the helper a publish uses - so a hidden record's + * document turns private exactly the way an unpublished one does. Then the + * configured front ends are told, like a scheduled publish tells them: hiding is + * usually urgent, and a cached page that outlives it defeats the point. + * + * Works for both services: a content type without `editorial` hands in an + * outcome with no revision, the same way the scheduled path builds one. + */ +export const contentVisibilityEffects = async ( + c: Context, + model: AnyContentModel, + outcome: ContentEditorialOutcome, + { pluginId }: Pick, +): Promise => { + if (!outcome.changed || !outcome.visibility) return NOTHING; + + const definition = contentDefinitionOf(model); + const effects = await contentEditorialEffects(c, definition, outcome, { + model, + pluginId, + }); + + const revalidation = await dispatchContentRevalidation( + c, + await contentVisibilityRevalidation(c, model, { + row: outcome.row, + visibility: outcome.visibility, + }), + ); + + return { ...effects, revalidation }; +}; diff --git a/packages/vitnode/src/content/server/visibility.test-d.ts b/packages/vitnode/src/content/server/visibility.test-d.ts new file mode 100644 index 0000000000..a268995800 --- /dev/null +++ b/packages/vitnode/src/content/server/visibility.test-d.ts @@ -0,0 +1,85 @@ +import type { Context } from "hono"; + +import { describe, expectTypeOf, it } from "vitest"; + +import { + testEditorialPostContentType, + testHideableNoteContentType, + testHideablePostContentType, +} from "@/tests/content-fixtures"; + +import type { ContentEditorialOutcome } from "./editorial-service"; +import type { ContentVisibilityResult } from "./visibility"; + +import { createContentModel } from "./model"; + +const hideablePosts = createContentModel(testHideablePostContentType); +const hideableNotes = createContentModel(testHideableNoteContentType); +const editorialPosts = createContentModel(testEditorialPostContentType); + +// Never executed - the type checker is the whole point. +const c = {} as Context; + +describe("hide and unhide on the services", () => { + it("exist on the plain repository of a content type with visibility", () => { + expectTypeOf(hideableNotes.service(c).hide) + .parameter(0) + .toEqualTypeOf(); + expectTypeOf( + hideableNotes.service(c).unhide, + ).returns.resolves.toEqualTypeOf | null>(); + }); + + it("exist on the editorial service, with an optional expectedVersion", () => { + expectTypeOf(hideablePosts.editorialService).not.toBeUndefined(); + type Editorial = NonNullable< + ReturnType> + >; + type HideOptions = Parameters[1]; + + expectTypeOf().toEqualTypeOf< + number | undefined + >(); + expectTypeOf[0]>().toEqualTypeOf(); + expectTypeOf< + Awaited> + >().toEqualTypeOf | null>(); + }); + + it("are absent without visibility", () => { + const editorial = editorialPosts.editorialService?.(c, { pluginId: "x" }); + + expectTypeOf(editorial?.hide).toEqualTypeOf(); + expectTypeOf(editorialPosts.service(c).unhide).toEqualTypeOf(); + }); + + it("type the visibility columns on a row only when they exist", () => { + type Row = NonNullable< + Awaited["findById"]>> + >; + type PlainRow = NonNullable< + Awaited["findById"]>> + >; + + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().not.toHaveProperty("hiddenAt"); + expectTypeOf< + (typeof hideableNotes.table.$inferSelect)["hiddenAt"] + >().toEqualTypeOf(); + }); + + it("accepts the visibility filter on the admin list only with visibility", () => { + void hideableNotes.service(c).findMany({ + filters: { status: "published", visibility: "hidden" }, + }); + void editorialPosts.service(c).findMany({ + // @ts-expect-error - no `hiddenAt`, so no visibility filter + filters: { visibility: "hidden" }, + }); + }); +}); diff --git a/packages/vitnode/src/content/server/visibility.ts b/packages/vitnode/src/content/server/visibility.ts new file mode 100644 index 0000000000..4fc880764c --- /dev/null +++ b/packages/vitnode/src/content/server/visibility.ts @@ -0,0 +1,217 @@ +import type { SQL } from "drizzle-orm"; +import type { PgColumn } from "drizzle-orm/pg-core"; + +import { isNotNull, isNull, sql } from "drizzle-orm"; + +import type { ContentIdOf } from "../ids"; +import type { ContentActor } from "../revisions"; +import type { AnyContentTypeDefinition, ContentSelect } from "../types"; +import type { ContentVisibilityAction } from "../visibility"; +import type { + ContentEditorialOutcome, + ContentEditorialService, +} from "./editorial-service"; +import type { ContentDatabase, ContentService } from "./service"; + +import { isContentPubliclyVisible } from "../cache"; +import { ContentEngineError } from "../errors"; + +export type { ContentVisibilityAction }; + +/** The visibility columns as they stood *before* a mutation. */ +export interface ContentVisibilityState { + hiddenAt: Date | null; + hiddenBy: null | number; +} + +/** + * What one hide or unhide did to public reachability, read off the row on both + * sides of the write rather than assumed from the operation: hiding a draft + * takes nothing off the site, and unhiding one puts nothing back. + */ +export interface ContentVisibilityChange { + /** Who asked, or `null` for the system or an anonymous caller. */ + actorUserId: null | number; + /** The visibility columns before the write. */ + before: ContentVisibilityState; + /** Publicly reachable after the mutation. */ + isPublic: boolean; + /** Publicly reachable before the mutation. */ + wasPublic: boolean; +} + +export interface ContentVisibilityOptions { + /** Stamped into `hiddenBy` by `hide`. `null` or absent for the system. */ + actorUserId?: null | number; + /** Run inside an existing transaction. */ + tx?: ContentDatabase; +} + +export interface ContentVisibilityResult { + /** + * `false` when the record was already in that state: nothing was written, no + * event is due and nothing needs invalidating. + */ + changed: boolean; + hiddenAt: Date | null; + hiddenBy: null | number; + row: ContentSelect; + visibility: ContentVisibilityChange; +} + +/** `hide` / `unhide` on the plain repository. Present only with `visibility`. */ +export interface ContentVisibilityMethods { + /** + * Idempotent. Stamps `hiddenAt = now()` and `hiddenBy`, and never touches + * `status` or `publishedAt`. `null` when the record does not exist. + */ + hide: ( + id: ContentIdOf, + options?: ContentVisibilityOptions, + ) => Promise | null>; + /** Idempotent. Clears both columns; the publication state is left alone. */ + unhide: ( + id: ContentIdOf, + options?: ContentVisibilityOptions, + ) => Promise | null>; +} + +export interface ContentEditorialVisibilityOptions { + actor: ContentActor; + /** + * Enforced when supplied, exactly like `publish`: hiding overwrites no field + * value, so a colleague's typo fix is not a reason to refuse it. + */ + expectedVersion?: number; + /** Join an existing transaction instead of opening one. */ + tx?: ContentDatabase; +} + +/** `hide` / `unhide` on the editorial service. Present only with `visibility`. */ +export interface ContentEditorialVisibilityMethods { + /** + * Bumps the version and writes a `hide` revision. A no-op - the record is + * already hidden - writes nothing and returns `changed: false`. + */ + hide: ( + id: ContentIdOf, + options: ContentEditorialVisibilityOptions, + ) => Promise | null>; + unhide: ( + id: ContentIdOf, + options: ContentEditorialVisibilityOptions, + ) => Promise | null>; +} + +/** The type-level gate: `hide`/`unhide` exist exactly when visibility does. */ +export type ContentVisibilityMembers = + TDefinition extends { visibility: { enabled: true } } + ? TMethods + : Partial>; + +/** Whether the row already is where `action` would put it. */ +export const isInVisibilityState = ( + action: ContentVisibilityAction, + row: Record, +): boolean => { + const hidden = row.hiddenAt !== null && row.hiddenAt !== undefined; + + return action === "hide" ? hidden : !hidden; +}; + +/** The guard that makes one visibility `UPDATE` a real state change. */ +export const visibilityGuard = ( + action: ContentVisibilityAction, + columns: Record, +): SQL => + action === "hide" ? isNull(columns.hiddenAt) : isNotNull(columns.hiddenAt); + +/** + * The `SET` of one visibility mutation. `now()` rather than a JavaScript date, so + * `hiddenAt` is on the same clock as `publishedAt` and the public predicate. + */ +export const visibilityValues = ( + action: ContentVisibilityAction, + actorUserId: null | number | undefined, +): Record => + action === "hide" + ? { hiddenAt: sql`now()`, hiddenBy: actorUserId ?? null } + : { hiddenAt: null, hiddenBy: null }; + +const toDate = (value: unknown): Date | null => { + if (value instanceof Date) return value; + if (typeof value !== "string") return null; + + const parsed = new Date(value); + + return Number.isNaN(parsed.getTime()) ? null : parsed; +}; + +export const visibilityStateOf = ( + row: Record, +): ContentVisibilityState => ({ + hiddenAt: toDate(row.hiddenAt), + hiddenBy: typeof row.hiddenBy === "number" ? row.hiddenBy : null, +}); + +/** Whether one base row is publicly reachable, `hiddenAt` included. */ +export const isContentBaseRowPublic = ( + row: null | Record, +): boolean => + row !== null && + isContentPubliclyVisible({ + hiddenAt: row.hiddenAt as Date | null | string | undefined, + publishedAt: row.publishedAt as Date | null | string | undefined, + status: typeof row.status === "string" ? row.status : undefined, + }); + +/** The reachability change between two rows the caller already holds. */ +export const visibilityChange = ({ + actorUserId, + after, + before, +}: { + actorUserId: null | number | undefined; + after: Record; + before: Record; +}): ContentVisibilityChange => ({ + actorUserId: actorUserId ?? null, + before: visibilityStateOf(before), + isPublic: isContentBaseRowPublic(after), + wasPublic: isContentBaseRowPublic(before), +}); + +const assertVisibility = (definition: AnyContentTypeDefinition): void => { + if (!definition.visibility.enabled) { + throw new ContentEngineError( + "hide/unhide need `visibility: { enabled: true }` on the content type.", + { contentTypeId: definition.id }, + ); + } +}; + +/** + * The plain repository's `hide`/`unhide`, for code written against an open + * definition - a route builder - where the conditional type has not resolved. + * The runtime flag is the same one the type reads. + */ +export const visibilityMethods = ( + definition: TDefinition, + service: ContentService, +): ContentVisibilityMethods => { + assertVisibility(definition); + + return service as unknown as ContentVisibilityMethods; +}; + +/** The editorial twin of {@link visibilityMethods}. */ +export const editorialVisibilityMethods = < + TDefinition extends AnyContentTypeDefinition, +>( + definition: TDefinition, + service: ContentEditorialService, +): ContentEditorialVisibilityMethods => { + assertVisibility(definition); + + return service as unknown as ContentEditorialVisibilityMethods; +}; diff --git a/packages/vitnode/src/content/types.ts b/packages/vitnode/src/content/types.ts index 501aa1748c..08d8ae49ff 100644 --- a/packages/vitnode/src/content/types.ts +++ b/packages/vitnode/src/content/types.ts @@ -16,6 +16,8 @@ import type { CONTENT_SITEMAP_CHANGE_FREQUENCIES, CONTENT_SYSTEM_FIELDS, CONTENT_TRANSLATION_SYSTEM_FIELDS, + CONTENT_VISIBILITY_FIELDS, + CONTENT_VISIBILITY_FILTERS, } from "./const"; import type { ContentFileDescriptor } from "./files"; import type { ContentIdStrategy } from "./ids"; @@ -29,6 +31,12 @@ export type ContentPublicationField = export type ContentEditorialField = (typeof CONTENT_EDITORIAL_FIELDS)[number]; +export type ContentVisibilityField = (typeof CONTENT_VISIBILITY_FIELDS)[number]; + +/** The admin list's `visibility` filter value. */ +export type ContentVisibilityFilter = + (typeof CONTENT_VISIBILITY_FILTERS)[number]; + export type ContentTranslationSystemField = (typeof CONTENT_TRANSLATION_SYSTEM_FIELDS)[number]; @@ -1858,6 +1866,9 @@ export type ContentFilterInput = Partial< Record, ContentRelationFilter> & (TDefinition extends { publication: { enabled: true } } ? { status: ContentPublicationStatus } + : Record) & + (TDefinition extends { visibility: { enabled: true } } + ? { visibility: ContentVisibilityFilter } : Record) & { [K in FilterableContentFieldName]: ContentFieldInput< ContentFieldsOf[K] diff --git a/packages/vitnode/src/content/visibility.ts b/packages/vitnode/src/content/visibility.ts new file mode 100644 index 0000000000..82f9fcba56 --- /dev/null +++ b/packages/vitnode/src/content/visibility.ts @@ -0,0 +1,43 @@ +import type { CONTENT_VISIBILITY_ACTIONS } from "./const"; +import type { + AnyContentTypeDefinition, + ContentVisibilityFilter, +} from "./types"; + +export type { ContentVisibilityFilter }; + +/** `hide` or `unhide` - the route segment each posts to, and the revision operation. */ +export type ContentVisibilityAction = + (typeof CONTENT_VISIBILITY_ACTIONS)[number]; + +/** Whether a row - as any admin response carries it - is hidden right now. */ +export const isContentHidden = (row: { hiddenAt?: unknown }): boolean => + row.hiddenAt !== null && row.hiddenAt !== undefined && row.hiddenAt !== ""; + +/** What pressing the one visibility toggle on a row does. */ +export interface ContentVisibilityTransition { + action: ContentVisibilityAction; + /** Whether pressing it takes something away from the public site. */ + destructive: boolean; + to: ContentVisibilityFilter; +} + +const HIDE: ContentVisibilityTransition = { + action: "hide", + destructive: true, + to: "hidden", +}; + +const UNHIDE: ContentVisibilityTransition = { + action: "unhide", + destructive: false, + to: "visible", +}; + +export const contentVisibilityTransition = (row: { + hiddenAt?: unknown; +}): ContentVisibilityTransition => (isContentHidden(row) ? UNHIDE : HIDE); + +export const hasContentVisibility = ( + definition: AnyContentTypeDefinition, +): boolean => definition.visibility.enabled; diff --git a/packages/vitnode/src/locales/en.json b/packages/vitnode/src/locales/en.json index 884d4ed528..9c248c6023 100644 --- a/packages/vitnode/src/locales/en.json +++ b/packages/vitnode/src/locales/en.json @@ -1527,7 +1527,9 @@ "publish": "Published", "unpublish": "Unpublished", "restore": "Restored", - "delete": "Deleted" + "delete": "Deleted", + "hide": "Hidden", + "unhide": "Unhidden" }, "restore": { "action": "Restore", diff --git a/packages/vitnode/src/tests/content-fixtures.ts b/packages/vitnode/src/tests/content-fixtures.ts index a0208e6075..fbb079bf47 100644 --- a/packages/vitnode/src/tests/content-fixtures.ts +++ b/packages/vitnode/src/tests/content-fixtures.ts @@ -702,3 +702,91 @@ export const testDuplicableNoteContentType = defineContentType({ list: { columns: ["title", "status", "updatedAt"] }, }, }); + +/** + * Record-level hiding on the fullest non-localized shape: editorial history, + * previews, scheduling, search and delivery with redirects, so every effect a + * hide or an unhide has is exercised by one content type. + */ +export const testHideablePostContentType = defineContentType({ + id: "test.hideable-post", + tableName: "test_hideable_posts", + publication: true, + visibility: true, + editorial: { + preview: { expiresInMinutes: 30 }, + scheduling: true, + }, + fields: { + title: field.text({ required: true, minLength: 3, maxLength: 200 }), + slug: field.slug({ source: "title" }), + excerpt: field.textarea({ maxLength: 500, nullable: true }), + }, + publicApi: { + path: "hideable-posts", + fields: ["id", "title", "slug", "excerpt", "publishedAt"], + defaultOrderBy: "publishedAt", + }, + search: { + titleField: "title", + contentFields: ["excerpt"], + pathTemplate: "/hideable-posts/{slug}", + }, + delivery: { + redirects: true, + seo: { titleField: "title" }, + sitemap: true, + }, + admin: { titleField: "title" }, +}); + +/** Hiding on a localized content type: one switch takes every language down. */ +export const testHideableLocalizedContentType = defineContentType({ + id: "test.hideable-localized", + tableName: "test_hideable_localized", + localization: { defaultLocale: "en", fallback: "default" }, + publication: true, + visibility: true, + editorial: true, + fields: { + title: field.text({ localized: true, required: true, maxLength: 200 }), + slug: field.slug({ localized: true, source: "title" }), + }, + publicApi: { + path: "hideable-localized", + fields: ["id", "title", "slug", "publishedAt"], + defaultOrderBy: "publishedAt", + }, + search: { + titleField: "title", + contentFields: ["title"], + pathTemplate: "/{locale}/hideable/{slug}", + }, + delivery: { + redirects: true, + hreflang: { xDefault: "defaultLocale" }, + seo: { titleField: "title" }, + sitemap: true, + }, +}); + +/** Hiding without `editorial`: the plain repository's `hide` and `unhide`. */ +export const testHideableNoteContentType = defineContentType({ + id: "test.hideable-note", + tableName: "test_hideable_notes", + publication: true, + visibility: true, + fields: { + title: field.text({ required: true, maxLength: 200 }), + slug: field.slug({ source: "title" }), + }, + publicApi: { + path: "hideable-notes", + fields: ["id", "title", "slug", "publishedAt"], + }, + search: { + titleField: "title", + contentFields: ["title"], + pathTemplate: "/hideable-notes/{slug}", + }, +}); diff --git a/packages/vitnode/src/views/admin/views/content/actions/history/revision-row.tsx b/packages/vitnode/src/views/admin/views/content/actions/history/revision-row.tsx index 289e91f9b1..1108bb2f1e 100644 --- a/packages/vitnode/src/views/admin/views/content/actions/history/revision-row.tsx +++ b/packages/vitnode/src/views/admin/views/content/actions/history/revision-row.tsx @@ -37,6 +37,10 @@ const OPERATION_TONES: Record< badge: "border-destructive/40 bg-destructive/10 text-destructive", dot: "bg-destructive", }, + hide: { + badge: "bg-muted text-muted-foreground", + dot: "bg-muted-foreground/40", + }, publish: { badge: "border-blue-500/40 bg-blue-500/10 text-blue-700 dark:text-blue-400", dot: "bg-blue-500", @@ -46,6 +50,10 @@ const OPERATION_TONES: Record< "border-violet-500/40 bg-violet-500/10 text-violet-700 dark:text-violet-400", dot: "bg-violet-500", }, + unhide: { + badge: "border-blue-500/40 bg-blue-500/10 text-blue-700 dark:text-blue-400", + dot: "bg-blue-500", + }, unpublish: { badge: "bg-muted text-muted-foreground", dot: "bg-muted-foreground/40", diff --git a/packages/vitnode/src/views/admin/views/content/form/mutations-api.ts b/packages/vitnode/src/views/admin/views/content/form/mutations-api.ts index 7d5a494fde..3f58e692a6 100644 --- a/packages/vitnode/src/views/admin/views/content/form/mutations-api.ts +++ b/packages/vitnode/src/views/admin/views/content/form/mutations-api.ts @@ -1,6 +1,7 @@ import { z } from "zod"; import type { ContentPublicationAction } from "@/content/publication"; +import type { ContentVisibilityAction } from "@/content/visibility"; import { CONTENT_OPTIONS_LIMIT } from "@/content/const"; @@ -201,6 +202,37 @@ export const setContentPublishedInBrowser = async ( return { version: versionOf(result.data?.row) }; }; +/** + * Hides or unhides the record the form is editing. + * + * Returns the version the record holds afterwards: on an editorial content type + * a hide is a new version with its own revision, and the form's next save needs + * that precondition rather than the one it opened with. A `409` carries + * `CONTENT_VERSION_CONFLICT` when `expectedVersion` was sent and somebody saved + * first, or `CONTENT_DELIVERY_SLUG_RESERVED` when an unhide finds its address + * taken by another record. + */ +export const setContentHiddenInBrowser = async ( + target: ContentApiTarget, + id: number, + action: ContentVisibilityAction, + expectedVersion?: number, +): Promise => { + const result = await send( + { + ...(expectedVersion === undefined ? {} : { body: { expectedVersion } }), + method: "post", + path: `/${id}/${action}`, + target, + }, + zodPublicationResult, + ); + + if (!succeeded(result, 200)) return failure(result); + + return { version: versionOf(result.data?.row) }; +}; + /** Re-reads one record - for the conflict banner and for missing collections. */ export const readContentRowInBrowser = async ( target: ContentApiTarget, diff --git a/packages/vitnode/src/views/admin/views/content/table/list-mutations.ts b/packages/vitnode/src/views/admin/views/content/table/list-mutations.ts index 64936b2a6a..aacd64125b 100644 --- a/packages/vitnode/src/views/admin/views/content/table/list-mutations.ts +++ b/packages/vitnode/src/views/admin/views/content/table/list-mutations.ts @@ -1,4 +1,5 @@ import type { ContentPublicationAction } from "@/content/publication"; +import type { ContentVisibilityAction } from "@/content/visibility"; import type { ContentMutationResult } from "../content-mutation"; import type { ContentApiTarget } from "../content-request"; @@ -123,6 +124,35 @@ export const duplicateContentInBrowser = async ({ return { duplicated, id: duplicated.id, status: response.status }; }; +/** + * Hides or unhides one record from the table. + * + * Idempotent on the server, like publishing: hiding a record that is already + * hidden is a `200` that changed nothing, so a stale row resolves itself. + * `expectedVersion` is optional and only checked by an editorial content type - + * send the version the row was rendered with when a `409` should stop the click + * on a record somebody else just changed. Gated by `can_hide`. + */ +export const setContentVisibilityInBrowser = async ({ + action, + expectedVersion, + id, + target, +}: ContentRowMutationArgs & { + /** The transition to perform, from `contentVisibilityTransition`. */ + action: ContentVisibilityAction; + expectedVersion?: number; +}): Promise => + await readResult( + async () => + await contentApiFetch({ + ...(expectedVersion === undefined ? {} : { body: { expectedVersion } }), + method: "post", + path: `/${id}/${action}`, + target, + }), + ); + /** * Deletes one record. * From 998b89cf339fe81659dfb8200d807bb20c0fd69f Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 08:37:17 +0000 Subject: [PATCH 04/17] =?UTF-8?q?feat(content):=20=E2=9C=A8=20AdminCP=20du?= =?UTF-8?q?plicate=20and=20hide/unhide,=20blog=20wiring?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AdminCP (generic Content Engine screens): - Row actions "Duplicate" (can_create + can_view, duplication-enabled types) and "Hide"/"Unhide" (can_hide, visibility-enabled types), each behind an accessible confirmation; sonner toasts with the record's title; list invalidation; a duplicate opens the copy's edit page (page mode) or edit dialog (dialog mode). Duplicate refusals name the field: slug conflict and unique-field-required map to field-specific messages. - "Hidden" badge (icon + text) beside the status in the list and the editor. - Status and Visibility single-value filters in the list toolbar (All / Visible / Hidden), wired to ?status= and ?visibility=; malformed values in the URL read as "all" instead of a 400. - Editor: Hide/Unhide control sent with the form's expectedVersion (409 opens the conflict banner), hidden notice in the status area, and "Publishing a hidden record does not make it public" in publish confirmations. - Content form transport gains optional setHidden; the form dialog slot can be opened without a trigger (open/onOpenChange). Blog: blog.post enables duplication and visibility; can_hide permission label; the article editor shows the hidden notice and inherits the badge and the Hide/Unhide control; the legacy event bridge documents why duplicated, hidden and unhidden are not re-emitted. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VpG3D33ZqXEYjN2N6eNcMK --- .../vitnode/src/components/table/filters.tsx | 57 +++- packages/vitnode/src/locales/en.json | 44 ++- .../tanstack/admin/content/form/dialog.tsx | 6 +- .../tanstack/admin/content/form/form-body.tsx | 1 + .../tanstack/admin/content/form/page-body.tsx | 1 + .../tanstack/admin/content/form/transport.ts | 13 + .../src/tanstack/admin/content/index.ts | 10 +- .../src/tanstack/admin/content/list.tsx | 104 +++++- .../src/tanstack/admin/content/query.ts | 118 +++++++ .../tanstack/admin/content/route-search.ts | 12 +- .../admin/content/row-actions.test.tsx | 323 ++++++++++++++++++ .../tanstack/admin/content/row-actions.tsx | 233 ++++++++++++- .../src/tanstack/admin/content/slots.ts | 11 +- .../views/content/actions/content-form.tsx | 87 ++++- .../content/actions/duplicate-dialog.tsx | 47 +++ .../views/content/actions/form-dialog.tsx | 12 +- .../content/actions/row-actions-model.test.ts | 64 ++++ .../content/actions/row-actions-model.ts | 57 +++- .../content/actions/row-dialogs.test.tsx | 157 +++++++++ .../content/actions/visibility-dialog.tsx | 70 ++++ .../admin/views/content/form/context.tsx | 14 + .../views/admin/views/content/form/index.ts | 6 + .../admin/views/content/form/primitives.tsx | 48 ++- .../views/content/form/publication-status.tsx | 7 + .../views/content/form/status-switch.tsx | 25 +- .../admin/views/content/form/transport.tsx | 13 + .../views/content/form/visibility.test.tsx | 168 +++++++++ .../admin/views/content/form/visibility.tsx | 120 +++++++ .../content/lib/duplicate-feedback.test.ts | 51 +++ .../views/content/lib/duplicate-feedback.ts | 49 +++ .../admin/views/content/lib/hidden-badge.tsx | 29 ++ .../views/content/page/content-form-page.tsx | 1 + .../admin/views/content/table/cells.test.tsx | 50 +++ .../views/admin/views/content/table/cells.tsx | 28 +- .../admin/views/content/table/columns.tsx | 6 +- .../views/content/table/list-filters.test.ts | 105 ++++++ .../admin/views/content/table/list-filters.ts | 82 +++++ plugins/blog/src/api/lib/events.ts | 11 + plugins/blog/src/content/post.ts | 8 + plugins/blog/src/locales/en.json | 1 + .../admin/article/editor/article-editor.tsx | 2 + 41 files changed, 2174 insertions(+), 77 deletions(-) create mode 100644 packages/vitnode/src/tanstack/admin/content/row-actions.test.tsx create mode 100644 packages/vitnode/src/views/admin/views/content/actions/duplicate-dialog.tsx create mode 100644 packages/vitnode/src/views/admin/views/content/actions/row-dialogs.test.tsx create mode 100644 packages/vitnode/src/views/admin/views/content/actions/visibility-dialog.tsx create mode 100644 packages/vitnode/src/views/admin/views/content/form/visibility.test.tsx create mode 100644 packages/vitnode/src/views/admin/views/content/form/visibility.tsx create mode 100644 packages/vitnode/src/views/admin/views/content/lib/duplicate-feedback.test.ts create mode 100644 packages/vitnode/src/views/admin/views/content/lib/duplicate-feedback.ts create mode 100644 packages/vitnode/src/views/admin/views/content/lib/hidden-badge.tsx create mode 100644 packages/vitnode/src/views/admin/views/content/table/list-filters.test.ts create mode 100644 packages/vitnode/src/views/admin/views/content/table/list-filters.ts diff --git a/packages/vitnode/src/components/table/filters.tsx b/packages/vitnode/src/components/table/filters.tsx index a307c7fe79..c2aab1fea4 100644 --- a/packages/vitnode/src/components/table/filters.tsx +++ b/packages/vitnode/src/components/table/filters.tsx @@ -32,8 +32,27 @@ export interface FilterDataTable { label: string; onSearch?: (search: string) => Promise; options?: FilterOption[]; + /** + * One value at a time, for a filter the API reads as a single equality - a + * status, a visibility. Picking an option replaces the selection, and + * `allLabel` names the first option, which clears it. + */ + single?: { allLabel: string }; } +/** What picking `value` leaves selected. */ +export const nextFilterSelection = ( + selected: readonly string[], + value: string, + single = false, +): string[] => { + if (single) return selected.includes(value) ? [] : [value]; + + return selected.includes(value) + ? selected.filter(item => item !== value) + : [...selected, value]; +}; + function FilterItem({ filter }: { filter: FilterDataTable }) { const t = useTranslations("core.global"); const { isPending, navigate, searchParams } = useDataTableUrl(); @@ -72,13 +91,9 @@ function FilterItem({ filter }: { filter: FilterDataTable }) { }; const toggle = (value: string) => { - const next = new Set(selectedSet); - if (next.has(value)) { - next.delete(value); - } else { - next.add(value); - } - applySelection([...next]); + applySelection( + nextFilterSelection(selected, value, Boolean(filter.single)), + ); }; const selectedStaticOptions = isAsync @@ -134,23 +149,35 @@ function FilterItem({ filter }: { filter: FilterDataTable }) { <> {t("results_not_found")} + {filter.single ? ( + applySelection([])} + value={filter.single.allLabel} + > + {filter.single.allLabel} + + ) : null} {options.map(option => { const isSelected = selectedSet.has(option.value); return ( toggle(option.value)} value={option.value} > - + {filter.single ? null : ( + + )} {option.label} ); @@ -158,7 +185,7 @@ function FilterItem({ filter }: { filter: FilterDataTable }) { )} - {selected.length > 0 && ( + {selected.length > 0 && !filter.single && ( <> diff --git a/packages/vitnode/src/locales/en.json b/packages/vitnode/src/locales/en.json index 9c248c6023..4a558b2b28 100644 --- a/packages/vitnode/src/locales/en.json +++ b/packages/vitnode/src/locales/en.json @@ -1392,6 +1392,9 @@ "published_at": "Published", "more_actions": "More actions" }, + "filters": { + "all": "All" + }, "actions": { "edit": "Edit", "publish": "Publish", @@ -1400,7 +1403,10 @@ "schedule": "Schedule", "history": "History", "delivery": "Delivery", - "delete": "Delete" + "delete": "Delete", + "duplicate": "Duplicate", + "hide": "Hide", + "unhide": "Unhide" }, "create": { "title": "Create {name}", @@ -1432,6 +1438,18 @@ "desc": "Someone saved it after this page loaded, so it was not deleted. Refresh the list, check what changed, and delete it again if you still want to." } }, + "duplicate": { + "title": "Duplicate {name}", + "desc": "Make a draft copy of ? Every field and translation is copied, and nothing about the copy is public until you publish it.", + "confirm": "Yes, duplicate it", + "success": "{name} duplicated as a draft.", + "skipped_title": "Some languages were left out", + "skipped": "Not copied: {locales}. Those languages are switched off on this site.", + "errors": { + "slug_conflict": "The copy needs its own {field}, and every address based on \"{slug}\" is taken. Change the original's {field}, then duplicate it again.", + "unique_required": "{count, plural, one {{fields} has to be unique} other {{fields} have to be unique}}, so the copy can't reuse the original's value. Create a new record instead, or change the value on the original first." + } + }, "bulk": { "publish": { "title": "Publish {count} {name}", @@ -1469,6 +1487,30 @@ "success": "{name} has been unpublished.", "action": "Unpublish" }, + "hide": { + "title": "Hide {name}", + "desc": "Hide ? It disappears from the public site, search and the sitemap right away. It is not unpublished - unhide it whenever you like.", + "confirm": "Yes, hide it", + "success": "{name} is now hidden." + }, + "unhide": { + "title": "Unhide {name}", + "desc": "Unhide ? Readers can reach it again only if it is published - a draft stays private.", + "confirm": "Yes, unhide it", + "success": "{name} is visible again." + }, + "visibility": { + "label": "Visibility", + "hidden": "Hidden", + "visible": "Visible", + "hidden_since": "Hidden since {date}", + "hidden_desc": "Hidden from the public site, search and the sitemap. Publishing a hidden record does not make it public.", + "publish_note": "Publishing a hidden record does not make it public.", + "conflict": { + "title": "This record changed", + "desc": "Someone saved it after this page loaded, so its visibility was not changed. The list has been refreshed - check what changed and try again." + } + }, "status": { "label": "Status", "draft": "Draft", diff --git a/packages/vitnode/src/tanstack/admin/content/form/dialog.tsx b/packages/vitnode/src/tanstack/admin/content/form/dialog.tsx index e06304fbbd..0ba05ce2b7 100644 --- a/packages/vitnode/src/tanstack/admin/content/form/dialog.tsx +++ b/packages/vitnode/src/tanstack/admin/content/form/dialog.tsx @@ -22,6 +22,8 @@ export const ContentAdminFormDialog = ({ action, children, entry, + onOpenChange, + open, row, singular, title, @@ -43,6 +45,8 @@ export const ContentAdminFormDialog = ({ /> } + onOpenChange={onOpenChange} + open={open} skeleton={ - {children as React.ReactElement} + {React.isValidElement(children) ? children : undefined} ); }; diff --git a/packages/vitnode/src/tanstack/admin/content/form/form-body.tsx b/packages/vitnode/src/tanstack/admin/content/form/form-body.tsx index 0e77f61d74..38e883a9e2 100644 --- a/packages/vitnode/src/tanstack/admin/content/form/form-body.tsx +++ b/packages/vitnode/src/tanstack/admin/content/form/form-body.tsx @@ -28,6 +28,7 @@ export const ContentDialogForm = ({ singular={singular} spec={spec} title={title} + visibility={entry.definition.visibility.enabled} /> ); }; diff --git a/packages/vitnode/src/tanstack/admin/content/form/page-body.tsx b/packages/vitnode/src/tanstack/admin/content/form/page-body.tsx index 627a71209c..dea0682cfc 100644 --- a/packages/vitnode/src/tanstack/admin/content/form/page-body.tsx +++ b/packages/vitnode/src/tanstack/admin/content/form/page-body.tsx @@ -119,6 +119,7 @@ const ContentEditScreen = ({ spec={spec} title={title} translations={translations} + visibility={entry.definition.visibility.enabled} /> ); }; diff --git a/packages/vitnode/src/tanstack/admin/content/form/transport.ts b/packages/vitnode/src/tanstack/admin/content/form/transport.ts index 2f69b04966..06a45cc8ae 100644 --- a/packages/vitnode/src/tanstack/admin/content/form/transport.ts +++ b/packages/vitnode/src/tanstack/admin/content/form/transport.ts @@ -16,6 +16,7 @@ import { listContentTranslationsInBrowser, loadContentOptionsInBrowser, readContentRowInBrowser, + setContentHiddenInBrowser, setContentPublishedInBrowser, } from "@/views/admin/views/content/form/mutations-api"; import { @@ -155,6 +156,18 @@ export const contentFormTransport = ( saveDraft: async (contentTypeId, itemId, draft) => await saveContentDraftInBrowser(targetFor(contentTypeId), itemId, draft), + setHidden: async (contentTypeId, itemId, action, expectedVersion) => { + const result = await setContentHiddenInBrowser( + targetFor(contentTypeId), + itemId, + action, + expectedVersion, + ); + + if (result.error === undefined) await settled(contentTypeId, itemId); + + return result; + }, unpublish: async (contentTypeId, itemId) => { const result = await setContentPublishedInBrowser( diff --git a/packages/vitnode/src/tanstack/admin/content/index.ts b/packages/vitnode/src/tanstack/admin/content/index.ts index 5dd01b1738..520f2e761a 100644 --- a/packages/vitnode/src/tanstack/admin/content/index.ts +++ b/packages/vitnode/src/tanstack/admin/content/index.ts @@ -18,8 +18,11 @@ export { contentListPageQuery, contentListRequestFor, deleteContentRow, + duplicateContentRow, invalidateContentAfterWrite, + readDuplicatedContent, setContentPublication, + setContentVisibility, } from "./query"; export type { ContentAdminRouteData, ContentAdminScreen } from "./route"; export { @@ -45,7 +48,10 @@ export { contentTableContract, normalizeContentListSearch, } from "./route-search"; -export type { ContentRowActionsProps } from "./row-actions"; +export type { + ContentDuplicatedRecord, + ContentRowActionsProps, +} from "./row-actions"; export { ContentRowActions } from "./row-actions"; export type { ContentAdminScreenProps } from "./screen"; export { ContentAdminScreenContent } from "./screen"; @@ -89,10 +95,12 @@ export { export type { ContentEditorialActionId, + ContentListActionId, ContentRowActionId, } from "@/views/admin/views/content/actions/row-actions-model"; export { CONTENT_EDITORIAL_ACTION_IDS, + CONTENT_LIST_ACTION_IDS, CONTENT_ROW_ACTION_IDS, CONTENT_ROW_INLINE_ACTION_LIMIT, contentRowActionIds, diff --git a/packages/vitnode/src/tanstack/admin/content/list.tsx b/packages/vitnode/src/tanstack/admin/content/list.tsx index f790222135..6e2d63d2e8 100644 --- a/packages/vitnode/src/tanstack/admin/content/list.tsx +++ b/packages/vitnode/src/tanstack/admin/content/list.tsx @@ -1,4 +1,5 @@ import { useSuspenseQuery } from "@tanstack/react-query"; +import { useNavigate } from "@tanstack/react-router"; import React from "react"; import { useTranslations } from "use-intl"; @@ -15,7 +16,11 @@ import type { ContentRowData } from "@/views/admin/views/content/table/cells"; import { ContentDataTable } from "@/components/table/content"; import { DataTableSkeleton } from "@/components/table/data-table-content"; import { DataTableNavigationProvider } from "@/components/table/navigation"; -import { buildContentColumnSpec, CONTENT_PERMISSIONS } from "@/content/index"; +import { + buildContentColumnSpec, + CONTENT_PERMISSIONS, + contentEditHref, +} from "@/content/index"; import { contentLabelsFrom } from "@/views/admin/views/content/content-labels"; import { contentBulkActions } from "@/views/admin/views/content/table/bulk-actions-model"; import { @@ -24,6 +29,7 @@ import { contentTableOrder, contentTableSearchEnabled, } from "@/views/admin/views/content/table/columns"; +import { contentListToolbarFilters } from "@/views/admin/views/content/table/list-filters"; import type { AdminTableNavigate } from "../table-search"; import type { @@ -31,6 +37,7 @@ import type { ContentListRouteSearch, UncheckedContentListSearch, } from "./route-search"; +import type { ContentDuplicatedRecord } from "./row-actions"; import { useLocale } from "../../i18n/locale"; import { useAdminPermission } from "../permissions"; @@ -43,6 +50,8 @@ import { contentListSearchParams, } from "./route-search"; import { ContentRowActions } from "./row-actions"; +import { ContentFormDialogSlot } from "./slot-render"; +import { contentAdminSlots } from "./slots"; /** What the list screen needs on top of the route data the loader returned. */ export interface ContentListScreenProps { @@ -91,6 +100,10 @@ const ContentListTable = ({ permission: CONTENT_PERMISSIONS.publish, plugin: pluginId, }); + const routerNavigate = useNavigate(); + const [duplicated, setDuplicated] = + React.useState(null); + const formDialog = contentAdminSlots().FormDialog; const isNarrowed = Boolean(params.search) || Object.keys(params.filters).length > 0; const bulkActions = contentBulkActions({ @@ -112,6 +125,45 @@ const ContentListTable = ({ [definition, navigate, search], ); + /** + * A fresh copy opens where this content type edits: its own page, or the + * edit dialog. Held here rather than in the row, because the refreshed list + * may no longer contain the row the copy was made from. + */ + const onDuplicated = React.useCallback( + (copy: ContentDuplicatedRecord) => { + if (definition.admin.edit.mode === "page") { + void routerNavigate({ + // The AdminCP's hrefs are still number-typed; a serial id is one. + to: contentEditHref(definition, copy.id as number), + }); + + return; + } + + setDuplicated(copy); + }, + [definition, routerNavigate], + ); + + const filters = React.useMemo( + () => + contentListToolbarFilters(definition, { + all: t("filters.all"), + status: { + draft: t("status.draft"), + label: t("status.label"), + published: t("status.published"), + }, + visibility: { + hidden: t("visibility.hidden"), + label: t("visibility.label"), + visible: t("visibility.visible"), + }, + }), + [definition, t], + ); + const columns = React.useMemo( () => buildContentTableColumns({ @@ -120,6 +172,9 @@ const ContentListTable = ({ empty: t("table.empty_value"), status: { draft: t("status.draft"), + ...(definition.visibility.enabled + ? { hidden: t("visibility.hidden") } + : {}), published: t("status.published"), }, }, @@ -127,13 +182,25 @@ const ContentListTable = ({ renderRowActions: (row: ContentRowData) => ( ), }), - [columnSpecs, entry, labels.singular, locale, registration, t], + [ + columnSpecs, + definition.visibility.enabled, + entry, + labels.labelField, + labels.singular, + locale, + onDuplicated, + registration, + t, + ], ); return ( @@ -156,11 +223,30 @@ const ContentListTable = ({ : { description: t("empty.desc"), title: t("empty.title") } } edges={data.edges} + filters={filters.length > 0 ? filters : undefined} id={`content-${definition.id}`} order={contentTableOrder(definition)} pageInfo={data.pageInfo} search={contentTableSearchEnabled(definition)} /> + + {duplicated && formDialog ? ( + { + if (!open) setDuplicated(null); + }} + open + row={{ labels: {}, ...duplicated.row }} + singular={labels.singular} + title={duplicated.title} + > + {null} + + ) : null} ); }; @@ -207,13 +293,18 @@ export const ContentListScreen = ({ }: ContentListScreenProps) => { const entry = registry.byId(contentTypeId); const t = useTranslations() as unknown as ContentLabelTranslator; + // Memoised so the row actions' `labelField` keeps its identity, and the + // table's columns are not rebuilt on every render of this screen. + const labels = React.useMemo( + () => (entry ? contentLabelsFrom(entry, t) : null), + [entry, t], + ); // The loader already answered `notFound()` for an unresolvable path, so this // is unreachable in a mounted route - and it is what lets everything below // read a resolved entry rather than an optional one. - if (!entry) return null; + if (!entry || !labels) return null; - const labels = contentLabelsFrom(entry, t); /** * The loader's parameters, or the same arithmetic run again. * @@ -233,7 +324,10 @@ export const ContentListScreen = ({ fallback={ } > diff --git a/packages/vitnode/src/tanstack/admin/content/query.ts b/packages/vitnode/src/tanstack/admin/content/query.ts index f397c32187..f95e695f1b 100644 --- a/packages/vitnode/src/tanstack/admin/content/query.ts +++ b/packages/vitnode/src/tanstack/admin/content/query.ts @@ -1,8 +1,11 @@ import type { QueryClient } from "@tanstack/react-query"; +import type { ContentId } from "@/content/ids"; import type { ContentPublicationAction } from "@/content/publication"; import type { AnyContentTypeDefinition } from "@/content/types"; +import type { ContentVisibilityAction } from "@/content/visibility"; import type { ContentApiTarget } from "@/views/admin/views/content/content-request"; +import type { ContentItem } from "@/views/admin/views/content/form/item-query"; import type { ContentDuplicateInput, ContentDuplicateMutationResult, @@ -11,6 +14,13 @@ import type { } from "@/views/admin/views/content/table/list-mutations"; import type { ContentListRequest } from "@/views/admin/views/content/table/list-query"; +import { + contentItemQueryOptions, + contentItemTitle, + contentTranslationsQueryOptions, + fetchContentItem, + fetchContentTranslations, +} from "@/views/admin/views/content/form/item-query"; import { invalidateContentItem, invalidateContentList, @@ -21,6 +31,7 @@ import { deleteContentInBrowser, duplicateContentInBrowser, setContentPublicationInBrowser, + setContentVisibilityInBrowser, } from "@/views/admin/views/content/table/list-mutations"; import { contentListQueryOptions, @@ -162,6 +173,113 @@ export const duplicateContentRow = async ( return result; }; +/** + * Hides or unhides a row, then refreshes everything that showed it. + * + * `expectedVersion` is the version the row was rendered at; an editorial + * content type answers a stale one with a `409` instead of acting on a record + * somebody else just changed. + */ +export const setContentVisibility = async ( + queryClient: QueryClient, + { + action, + contentTypeId, + expectedVersion, + id, + target, + }: ContentRowWriteArgs & { + /** The transition to perform, from `contentVisibilityTransition`. */ + action: ContentVisibilityAction; + expectedVersion?: number; + }, +): Promise => { + const result = await setContentVisibilityInBrowser({ + action, + expectedVersion, + id, + target, + }); + + if (result.error === undefined) { + await invalidateContentAfterWrite(queryClient, { + contentTypeId, + itemId: id, + }); + } + + return result; +}; + +/** + * The copy a duplicate just made, read the way its edit screen reads it. + * + * Through the same query definitions the edit page's loader uses, so the + * navigation that follows paints from the cache - and so a localized title, + * which is not on the duplicate's base row, comes from the copy's own + * translation rather than from a guess about the suffix the server added. + * Falls back to the duplicate's own row when the read fails: the copy exists + * either way, and the toast and the hand-off must not depend on a second + * request. + */ +export const readDuplicatedContent = async ( + queryClient: QueryClient, + { + definition, + fallback, + id, + locale, + pluginId, + }: { + definition: AnyContentTypeDefinition; + /** The `row` the duplicate answered with. */ + fallback: Record; + id: ContentId; + /** The language the administrator reads the AdminCP in. */ + locale: string; + pluginId: string; + }, +): Promise<{ row: ContentItem; title: string }> => { + const request = { + contentTypeId: definition.id, + // The AdminCP's query keys are still number-typed; a serial id is one. + itemId: id as number, + target: contentApiTarget(definition, pluginId), + }; + const base: ContentItem = { ...fallback, id: request.itemId }; + + try { + const [row, translations] = await Promise.all([ + queryClient.query({ + ...contentItemQueryOptions({ fetchItem: fetchContentItem, request }), + }), + definition.localization.enabled + ? queryClient.query({ + ...contentTranslationsQueryOptions({ + fetchTranslations: fetchContentTranslations, + request, + }), + }) + : Promise.resolve([]), + ]); + + return { + row, + title: contentItemTitle({ definition, locale, row, translations }), + }; + } catch { + return { + row: base, + title: contentItemTitle({ + definition, + locale, + row: base, + translations: [], + }), + }; + } +}; + /** Deletes a row, then drops everything that was about it. */ export const deleteContentRow = async ( queryClient: QueryClient, diff --git a/packages/vitnode/src/tanstack/admin/content/route-search.ts b/packages/vitnode/src/tanstack/admin/content/route-search.ts index 56d0949f9a..406dc34bfb 100644 --- a/packages/vitnode/src/tanstack/admin/content/route-search.ts +++ b/packages/vitnode/src/tanstack/admin/content/route-search.ts @@ -6,6 +6,7 @@ import type { import { CONTENT_DEFAULT_PAGE_SIZE } from "@/content/const"; import { contentFilterableFields, orderableColumns } from "@/content/registry"; +import { contentListSystemFilterValue } from "@/views/admin/views/content/table/list-filters"; import type { AdminTableRouteSearch, @@ -59,7 +60,16 @@ export const contentListFilters = ( return Object.fromEntries( contentFilterableFields(definition) - .map(name => [name, readParam(source[name])?.trim() ?? ""] as const) + .map( + name => + [ + name, + contentListSystemFilterValue( + name, + readParam(source[name])?.trim() ?? "", + ) ?? "", + ] as const, + ) .filter(([, value]) => value !== "") .sort(([a], [b]) => a.localeCompare(b)), ); diff --git a/packages/vitnode/src/tanstack/admin/content/row-actions.test.tsx b/packages/vitnode/src/tanstack/admin/content/row-actions.test.tsx new file mode 100644 index 0000000000..b81fccf272 --- /dev/null +++ b/packages/vitnode/src/tanstack/admin/content/row-actions.test.tsx @@ -0,0 +1,323 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { requestHandler } from "@tanstack/react-start/server"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { toast } from "sonner"; +import { IntlProvider } from "use-intl"; +import { + afterEach, + aroundEach, + beforeEach, + describe, + expect, + it, + vi, +} from "vitest"; + +import type { PermissionsStaffArgs } from "@/api/lib/permission-staff"; +import type { RegisteredFrontendContentType } from "@/content/index"; +import type { ContentRowData } from "@/views/admin/views/content/table/cells"; + +import { AdminStaffPermissionProvider } from "@/components/staff-permission/provider"; +import { defineContentType } from "@/content/define"; +import { field } from "@/content/fields"; + +import type { ContentDuplicatedRecord } from "./row-actions"; + +import { ContentRowActions } from "./row-actions"; + +const PLUGIN = "@vitnode/blog"; + +const articles = defineContentType({ + id: "blog.post", + tableName: "blog_posts", + publication: true, + visibility: true, + duplication: true, + editorial: true, + fields: { + title: field.text({ required: true, maxLength: 255 }), + sku: field.text({ required: true, unique: true }), + }, + admin: { titleField: "title", permissionModule: "posts" }, +}); + +const entry: RegisteredFrontendContentType = { + definition: articles, + pluginId: PLUGIN, + registration: { definition: articles }, +}; + +const row = (overrides: Partial = {}): ContentRowData => ({ + hiddenAt: null, + id: 7, + labels: {}, + status: "published", + title: "Release notes", + version: 4, + ...overrides, +}); + +const grant = (...permissions: string[]): PermissionsStaffArgs[] => + permissions.map(permission => ({ + module: "posts", + permission, + plugin: PLUGIN, + })); + +const json = (status: number, body: unknown) => + new Response(JSON.stringify(body), { + headers: { "content-type": "application/json" }, + status, + }); + +let respond: (init?: RequestInit) => Response = () => json(200, {}); + +const fetchMock = vi.fn( + async (_url: RequestInfo | URL, init?: RequestInit) => + await Promise.resolve(respond(init)), +); + +const sent = () => + fetchMock.mock.calls.map(([url, init]) => ({ + body: + typeof init?.body === "string" + ? (JSON.parse(init.body) as unknown) + : undefined, + method: init?.method?.toUpperCase(), + path: new URL(url instanceof Request ? url.url : url).pathname, + })); + +const mount = ({ + onDuplicated, + permissions, + record = row(), +}: { + onDuplicated?: (copy: ContentDuplicatedRecord) => void; + permissions: PermissionsStaffArgs[]; + record?: ContentRowData; +}) => + render( + + + + (name === "sku" ? "SKU" : name)} + locale="en" + onDuplicated={onDuplicated} + row={record} + singular="Article" + /> + + + , + ); + +const API_ORIGIN = "http://api.test"; + +/** + * The universal fetcher runs its server half under vitest, which reads the + * request it is rendering inside - so each test runs inside one, the way + * `mutations-api.test.ts` does. + */ +aroundEach(async runTest => { + await requestHandler(async () => { + await runTest(); + + return new Response(null, { status: 204 }); + })(new Request(`${API_ORIGIN}/admin/content/blog/articles`), undefined); +}); + +beforeEach(() => { + fetchMock.mockClear(); + vi.stubEnv("VITNODE_API_URL", API_ORIGIN); + vi.stubGlobal("fetch", fetchMock); +}); + +afterEach(() => { + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +describe("ContentRowActions - permission gating", () => { + it("offers Duplicate and Hide to an editor who may create, view and hide", () => { + mount({ permissions: grant("can_view", "can_create", "can_hide") }); + + expect( + screen.getByRole("button", { name: "core.content.actions.duplicate" }), + ).toBeTruthy(); + expect( + screen.getByRole("button", { name: "core.content.actions.hide" }), + ).toBeTruthy(); + }); + + it("offers no Duplicate without can_create", () => { + mount({ permissions: grant("can_view", "can_hide") }); + + expect( + screen.queryByRole("button", { name: "core.content.actions.duplicate" }), + ).toBeNull(); + expect( + screen.getByRole("button", { name: "core.content.actions.hide" }), + ).toBeTruthy(); + }); + + it("offers no Hide without can_hide - can_publish is not enough", () => { + mount({ permissions: grant("can_view", "can_create", "can_publish") }); + + expect( + screen.queryByRole("button", { name: "core.content.actions.hide" }), + ).toBeNull(); + }); + + it("offers Unhide for a hidden row", () => { + mount({ + permissions: grant("can_hide"), + record: row({ hiddenAt: "2026-10-01T09:00:00.000Z" }), + }); + + expect( + screen.getByRole("button", { name: "core.content.actions.unhide" }), + ).toBeTruthy(); + expect( + screen.queryByRole("button", { name: "core.content.actions.hide" }), + ).toBeNull(); + }); +}); + +describe("ContentRowActions - hide", () => { + it("hides with the row's version after the confirmation, then says so", async () => { + const success = vi.spyOn(toast, "success"); + respond = () => json(200, { changed: true, row: row({ version: 5 }) }); + mount({ permissions: grant("can_hide") }); + + fireEvent.click( + screen.getByRole("button", { name: "core.content.actions.hide" }), + ); + expect(await screen.findByText("core.content.hide.desc")).toBeTruthy(); + expect(fetchMock).not.toHaveBeenCalled(); + + fireEvent.click( + screen.getByRole("button", { name: "core.content.hide.confirm" }), + ); + + await waitFor(() => { + expect(success).toHaveBeenCalledWith("core.content.hide.success", { + description: "Release notes", + }); + }); + expect(sent()[0]).toEqual({ + body: { expectedVersion: 4 }, + method: "POST", + path: "/api/@vitnode/blog/admin/content/posts/7/hide", + }); + }); + + it("explains a version conflict instead of hiding", async () => { + const error = vi.spyOn(toast, "error"); + respond = () => + json(409, { + code: "CONTENT_VERSION_CONFLICT", + contentTypeId: "blog.post", + currentVersion: 6, + expectedVersion: 4, + itemId: 7, + }); + mount({ permissions: grant("can_hide") }); + + fireEvent.click( + screen.getByRole("button", { name: "core.content.actions.hide" }), + ); + fireEvent.click( + await screen.findByRole("button", { name: "core.content.hide.confirm" }), + ); + + await waitFor(() => { + expect(error).toHaveBeenCalledWith( + "core.content.visibility.conflict.title", + { description: "core.content.visibility.conflict.desc" }, + ); + }); + }); +}); + +describe("ContentRowActions - duplicate", () => { + it("copies the row, names the copy, and hands it to the list", async () => { + const success = vi.spyOn(toast, "success"); + const onDuplicated = vi.fn(); + const copy = row({ + id: 8, + status: "draft", + title: "Release notes (Copy)", + version: 1, + }); + respond = init => + init?.method?.toUpperCase() === "POST" + ? json(201, { + id: 8, + locales: [], + row: copy, + skippedLocales: [], + sourceId: 7, + }) + : json(200, copy); + mount({ + onDuplicated, + permissions: grant("can_view", "can_create"), + }); + + fireEvent.click( + screen.getByRole("button", { name: "core.content.actions.duplicate" }), + ); + fireEvent.click( + await screen.findByRole("button", { + name: "core.content.duplicate.confirm", + }), + ); + + await waitFor(() => { + expect(onDuplicated).toHaveBeenCalledTimes(1); + }); + expect(onDuplicated.mock.calls[0]?.[0]).toMatchObject({ + id: 8, + title: "Release notes (Copy)", + }); + expect(success).toHaveBeenCalledWith("core.content.duplicate.success", { + description: "Release notes (Copy)", + }); + expect(sent()[0]).toMatchObject({ + method: "POST", + path: "/api/@vitnode/blog/admin/content/posts/7/duplicate", + }); + }); + + it("names the unique field a copy cannot reuse", async () => { + const error = vi.spyOn(toast, "error"); + const onDuplicated = vi.fn(); + respond = () => + json(422, { + code: "CONTENT_DUPLICATE_UNIQUE_REQUIRED", + contentTypeId: "blog.post", + fields: ["sku"], + }); + mount({ onDuplicated, permissions: grant("can_view", "can_create") }); + + fireEvent.click( + screen.getByRole("button", { name: "core.content.actions.duplicate" }), + ); + fireEvent.click( + await screen.findByRole("button", { + name: "core.content.duplicate.confirm", + }), + ); + + await waitFor(() => { + expect(error).toHaveBeenCalledWith("core.global.errors.title", { + description: "core.content.duplicate.errors.unique_required", + }); + }); + expect(onDuplicated).not.toHaveBeenCalled(); + expect(screen.getByText("core.content.duplicate.title")).toBeTruthy(); + }); +}); diff --git a/packages/vitnode/src/tanstack/admin/content/row-actions.tsx b/packages/vitnode/src/tanstack/admin/content/row-actions.tsx index d24a1649cc..9719d981e8 100644 --- a/packages/vitnode/src/tanstack/admin/content/row-actions.tsx +++ b/packages/vitnode/src/tanstack/admin/content/row-actions.tsx @@ -2,6 +2,7 @@ import { useQueryClient } from "@tanstack/react-query"; import { Link } from "@tanstack/react-router"; import { CalendarClockIcon, + CopyPlusIcon, EllipsisIcon, EyeIcon, EyeOffIcon, @@ -13,10 +14,15 @@ import { } from "lucide-react"; import React from "react"; import { toast } from "sonner"; -import { useTranslations } from "use-intl"; +import { useFormatter, useTranslations } from "use-intl"; +import type { ContentId } from "@/content/ids"; import type { RegisteredFrontendContentType } from "@/content/index"; -import type { ContentRowActionId } from "@/views/admin/views/content/actions/row-actions-model"; +import type { + ContentEditorialActionId, + ContentRowActionId, +} from "@/views/admin/views/content/actions/row-actions-model"; +import type { ContentItem } from "@/views/admin/views/content/form/item-query"; import type { ContentRowData } from "@/views/admin/views/content/table/cells"; import type { ContentRowMutationResult } from "@/views/admin/views/content/table/list-mutations"; @@ -35,16 +41,29 @@ import { TooltipProvider, TooltipTrigger, } from "@/components/ui/tooltip"; +import { humanizeFieldName } from "@/content/admin/labels"; import { CONTENT_PERMISSIONS, contentEditHref, contentPublicationTransition, } from "@/content/index"; import { + contentVisibilityTransition, + isContentHidden, +} from "@/content/visibility"; +import { ContentDuplicateDialog } from "@/views/admin/views/content/actions/duplicate-dialog"; +import { + CONTENT_EDITORIAL_ACTION_IDS, + CONTENT_LIST_ACTION_IDS, contentRowActionIds, contentRowActionsAreInline, isDestructiveContentRowAction, } from "@/views/admin/views/content/actions/row-actions-model"; +import { + CONTENT_VISIBILITY_ICONS, + ContentVisibilityDialog, +} from "@/views/admin/views/content/actions/visibility-dialog"; +import { contentDuplicateErrorMessage } from "@/views/admin/views/content/lib/duplicate-feedback"; import { contentErrorKey } from "@/views/admin/views/content/lib/mutation-feedback"; import { contentRowTitle } from "@/views/admin/views/content/table/columns"; @@ -52,15 +71,35 @@ import { useAdminPermission } from "../permissions"; import { contentApiTarget, deleteContentRow, + duplicateContentRow, + invalidateContentList, + readDuplicatedContent, setContentPublication, + setContentVisibility, } from "./query"; import { ContentFormDialogSlot, ContentRowPanelSlot } from "./slot-render"; import { contentAdminSlots, registeredContentRowPanels } from "./slots"; +/** The copy a duplicate made, read back so the list can open it. */ +export interface ContentDuplicatedRecord { + id: ContentId; + /** The copy as its edit screen reads it - from the cache that read warmed. */ + row: ContentItem; + /** The copy's resolved title, suffix included. */ + title: string; +} + export interface ContentRowActionsProps { entry: RegisteredFrontendContentType; + /** A field's label, for an error that names one. Humanised when absent. */ + labelField?: (name: string) => string; /** The language the list is being read in. */ locale: string; + /** + * Hands a fresh copy to the list, which opens it - its edit page, or the edit + * dialog. Absent, the copy simply appears in the refreshed list. + */ + onDuplicated?: (copy: ContentDuplicatedRecord) => void; row: ContentRowData; /** The content type's noun, as this administrator reads it. */ singular: string; @@ -70,6 +109,11 @@ export interface ContentRowActionsProps { const versionOf = (row: ContentRowData): number => typeof row.version === "number" ? row.version : 1; +const isEditorialAction = ( + id: ContentRowActionId, +): id is ContentEditorialActionId => + (CONTENT_EDITORIAL_ACTION_IDS as readonly string[]).includes(id); + const useMutationToast = () => { const tErrors = useTranslations("core.global.errors"); const tContentErrors = useTranslations("core.content.errors"); @@ -78,6 +122,7 @@ const useMutationToast = () => { (result: ContentRowMutationResult) => { const errorKey = contentErrorKey(result.status, { ...(result.conflict ? { conflict: result.conflict } : {}), + ...(result.delivery ? { delivery: result.delivery } : {}), }); toast.error(tErrors("title"), { @@ -105,6 +150,7 @@ const PublishRowAction = ({ const tPublish = useTranslations("core.content.publish"); const tUnpublish = useTranslations("core.content.unpublish"); const tActions = useTranslations("core.content.actions"); + const tVisibility = useTranslations("core.content.visibility"); const queryClient = useQueryClient(); const showError = useMutationToast(); const canPublish = useAdminPermission({ @@ -121,16 +167,24 @@ const PublishRowAction = ({ const t = published ? tUnpublish : tPublish; const label = tActions(action); const Icon = published ? EyeOffIcon : SendIcon; + const description = t.rich("desc", { + title: () => {title}, + }); return ( ( - {title} - ), - })} + description={ + action === "publish" && + isContentHidden({ hiddenAt: row.hiddenAt }) ? ( + <> + {description} {tVisibility("publish_note")} + + ) : ( + description + ) + } icon={} onSubmit={async ({ onClose }) => { const result = await setContentPublication(queryClient, { @@ -248,9 +302,13 @@ const EditRowAction = ({ }; /** The icon each row action wears. Order and gating are the shared model's. */ -const ACTION_ICONS: Record = { +const ACTION_ICONS: Record< + Exclude, + React.ReactNode +> = { delete: , delivery: , + duplicate: , history: , preview: , schedule: , @@ -258,20 +316,23 @@ const ACTION_ICONS: Record = { const ContentRowActionsMenu = ({ entry, + labelField = humanizeFieldName, locale, + onDuplicated, row, singular, title, -}: { - entry: RegisteredFrontendContentType; - locale: string; +}: Omit & { row: ContentRowData; - singular: string; title: string; }) => { const { definition, pluginId } = entry; const t = useTranslations("core.content"); const tDelete = useTranslations("core.content.delete"); + const tDuplicate = useTranslations("core.content.duplicate"); + const tVisibility = useTranslations("core.content.visibility"); + const tErrors = useTranslations("core.global.errors"); + const format = useFormatter(); const queryClient = useQueryClient(); const showError = useMutationToast(); const triggerRef = React.useRef(null); @@ -286,32 +347,56 @@ const ContentRowActionsMenu = ({ permission: CONTENT_PERMISSIONS.view, plugin: pluginId, }); + const canCreate = useAdminPermission({ + module, + permission: CONTENT_PERMISSIONS.create, + plugin: pluginId, + }); const canPublish = useAdminPermission({ module, permission: CONTENT_PERMISSIONS.publish, plugin: pluginId, }); + const canHide = useAdminPermission({ + module, + permission: CONTENT_PERMISSIONS.hide, + plugin: pluginId, + }); const canDelete = useAdminPermission({ module, permission: CONTENT_PERMISSIONS.delete, plugin: pluginId, }); + const target = contentApiTarget(definition, pluginId); + const visibility = contentVisibilityTransition({ + hiddenAt: row.hiddenAt, + }); const slots = contentAdminSlots(); const items = contentRowActionIds({ + canCreate, canDelete, + canHide, canPublish, canView, delivery: definition.delivery.enabled, + duplication: definition.duplication.enabled, editorial: definition.editorial.enabled, preview: definition.editorial.preview.enabled, - renderable: ["delete", ...registeredContentRowPanels(slots)], + renderable: [ + ...CONTENT_LIST_ACTION_IDS, + ...registeredContentRowPanels(slots), + ], scheduling: definition.editorial.scheduling.enabled, + visibility: definition.visibility.enabled, }).map(id => ({ destructive: isDestructiveContentRowAction(id), - icon: ACTION_ICONS[id], + icon: + id === "visibility" + ? CONTENT_VISIBILITY_ICONS[visibility.action] + : ACTION_ICONS[id], id, - label: t(`actions.${id}`), + label: t(`actions.${id === "visibility" ? visibility.action : id}`), })); if (items.length === 0) return null; @@ -328,8 +413,95 @@ const ContentRowActionsMenu = ({ setPanel({ id, open: true }); }; + const duplicate = async (): Promise => { + const result = await duplicateContentRow(queryClient, { + contentTypeId: definition.id, + id: row.id, + target, + }); + + if (result.error !== undefined || !result.duplicated) { + const message = contentDuplicateErrorMessage(result.duplicate, { + labelField, + list: names => format.list(names), + }); + + if (message) { + toast.error(tErrors("title"), { + description: tDuplicate(`errors.${message.key}`, message.values), + }); + } else { + showError(result); + } + + return false; + } + + const { id, row: fallback, skippedLocales } = result.duplicated; + const copy = await readDuplicatedContent(queryClient, { + definition, + fallback, + id, + locale, + pluginId, + }); + + toast.success(tDuplicate("success", { name: singular }), { + description: copy.title, + }); + if (skippedLocales.length > 0) { + toast.info(tDuplicate("skipped_title"), { + description: tDuplicate("skipped", { + locales: format.list(skippedLocales), + }), + }); + } + + onDuplicated?.({ id, row: copy.row, title: copy.title }); + + return true; + }; + + const toggleVisibility = async (): Promise => { + const { action } = visibility; + const result = await setContentVisibility(queryClient, { + action, + contentTypeId: definition.id, + expectedVersion: definition.editorial.enabled + ? versionOf(row) + : undefined, + id: row.id, + target, + }); + + if (result.error !== undefined) { + if (result.conflict?.code === "CONTENT_VERSION_CONFLICT") { + // The row is stale. Re-read the list, so the next press carries the + // version somebody else just saved rather than conflicting again. + await invalidateContentList(queryClient, definition.id); + toast.error(tVisibility("conflict.title"), { + description: tVisibility("conflict.desc"), + }); + + return false; + } + + showError(result); + + return false; + } + + toast.success(t(`${action}.success`, { name: singular }), { + description: title, + }); + + return true; + }; + const rowPanel = - panel && panel.id !== "delete" ? slots.rowPanels?.[panel.id] : undefined; + panel && isEditorialAction(panel.id) + ? slots.rowPanels?.[panel.id] + : undefined; return ( <> @@ -391,6 +563,29 @@ const ContentRowActionsMenu = ({ )} + {panel?.id === "duplicate" ? ( + + ) : null} + + {panel?.id === "visibility" ? ( + + ) : null} + {panel?.id === "delete" ? ( { @@ -480,7 +677,9 @@ export const ContentRowActions = ({ /> void; + open?: boolean; /** The row being edited. Absent for `create`. */ row?: ContentRowData; /** The content type's noun, as this administrator reads it. */ diff --git a/packages/vitnode/src/views/admin/views/content/actions/content-form.tsx b/packages/vitnode/src/views/admin/views/content/actions/content-form.tsx index 83d909f775..5f6b13a12f 100644 --- a/packages/vitnode/src/views/admin/views/content/actions/content-form.tsx +++ b/packages/vitnode/src/views/admin/views/content/actions/content-form.tsx @@ -5,6 +5,7 @@ import { useLocale, useTranslations } from "use-intl"; import type { ItemAutoFormComponentProps } from "@/components/form/auto-form"; import type { ContentFormSpec } from "@/content/admin/spec"; import type { ContentFileFieldValue } from "@/content/files"; +import type { ContentVisibilityAction } from "@/content/visibility"; import type { ContentFormLayout } from "@/lib/plugin"; import { AutoForm, type AutoFormOnSubmit } from "@/components/form/auto-form"; @@ -43,6 +44,7 @@ import { contentSpecSkeletonShape, } from "../form/skeleton"; import { useContentFormTransport } from "../form/transport"; +import { ContentFormHiddenNotice } from "../form/visibility"; import { ContentField } from "../lib/field-component"; import { contentErrorKey } from "../lib/mutation-feedback"; import { useInvalidateContentOptions } from "../lib/options-query"; @@ -70,6 +72,8 @@ export interface ContentFormProps { spec: ContentFormSpec; title?: string; translations?: readonly TranslationRow[]; + /** `definition.visibility.enabled` - the Hidden badge and Hide / Unhide. */ + visibility?: boolean; } export const ContentForm = ({ @@ -189,6 +193,7 @@ const ContentFormFields = ({ spec, title, translations = [], + visibility = false, }: ContentFormProps & { live?: ContentLiveFormState; }) => { @@ -206,10 +211,27 @@ const ContentFormFields = ({ permission: CONTENT_PERMISSIONS.publish, plugin: spec.pluginId, }); + const canHide = useAdminStaffPermission({ + module: spec.permissionModule, + permission: CONTENT_PERMISSIONS.hide, + plugin: spec.pluginId, + }); const [conflict, setConflict] = React.useState( null, ); + // What the server last said, and what this screen knows since: a hide or an + // unhide answers before the record is re-read, and a dialog-mode form is + // never re-read at all. + const serverHiddenAt: unknown = data?.hiddenAt ?? null; + const [hidden, setHidden] = React.useState<{ + server: unknown; + value: unknown; + }>(() => ({ server: serverHiddenAt, value: serverHiddenAt })); + if (hidden.server !== serverHiddenAt) { + setHidden({ server: serverHiddenAt, value: serverHiddenAt }); + } + const files = data?.files as | Record | undefined; @@ -325,6 +347,50 @@ const ContentFormFields = ({ }, [], ); + const visibilityTransition = async (action: ContentVisibilityAction) => { + if (!data || !transport.setHidden) return false; + + const mutation = await transport.setHidden( + spec.contentTypeId, + data.id, + action, + expectedVersion, + ); + + if (mutation.error !== undefined) { + // The same banner a save gets: somebody saved first, and the editor + // decides with both versions in front of them. The dialog closes, because + // the banner is where the next step is. + if (mutation.conflict?.code === "CONTENT_VERSION_CONFLICT") { + setConflict({ currentVersion: mutation.conflict.currentVersion }); + + return true; + } + + const errorKey = contentErrorKey(mutation.status, mutation); + + toast.error(tErrors("title"), { + description: errorKey + ? tContentErrors(errorKey) + : tErrors("internal_server_error"), + }); + + return false; + } + + if (mutation.version !== undefined) setExpectedVersion(mutation.version); + setHidden(current => ({ + ...current, + value: action === "hide" ? new Date().toISOString() : null, + })); + + toast.success(t(`${action}.success`, { name: singular }), { + description: title, + }); + refresh(); + + return true; + }; const onSubmit: AutoFormOnSubmit = async ( submitted, @@ -530,6 +596,15 @@ const ContentFormFields = ({ status: row.status, updatedAt: row.updatedAt, })), + visibility: { + canHide, + enabled: visibility, + hiddenAt: hidden.value, + transition: + data && transport.setHidden + ? visibilityTransition + : undefined, + }, }} > {Layout ? ( @@ -553,10 +628,14 @@ const ContentFormFields = ({ )} {publication && data ? ( - +
+ + +
) : null} diff --git a/packages/vitnode/src/views/admin/views/content/actions/duplicate-dialog.tsx b/packages/vitnode/src/views/admin/views/content/actions/duplicate-dialog.tsx new file mode 100644 index 0000000000..efd42d7609 --- /dev/null +++ b/packages/vitnode/src/views/admin/views/content/actions/duplicate-dialog.tsx @@ -0,0 +1,47 @@ +import { CopyPlusIcon } from "lucide-react"; +import { useTranslations } from "use-intl"; + +import { ConfirmActionAlertDialog } from "@/components/confirm-action/confirm-action-alert-dialog"; + +/** + * "Duplicate this record?" - the copy is a draft, with every translation, and + * nothing about it is public until somebody publishes it. + * + * `onConfirm` performs the copy and reports back: `true` closes the dialog, + * `false` keeps it open so the administrator can read the error and decide. + */ +export const ContentDuplicateDialog = ({ + finalFocus, + onConfirm, + onOpenChange, + open, + singular, + title, +}: { + finalFocus?: React.RefObject; + onConfirm: () => Promise; + onOpenChange?: (open: boolean) => void; + open?: boolean; + singular: string; + title: string; +}) => { + const t = useTranslations("core.content.duplicate"); + + return ( + {title}, + })} + finalFocus={finalFocus} + icon={} + onOpenChange={onOpenChange} + onSubmit={async ({ onClose }) => { + if (await onConfirm()) onClose(); + }} + open={open} + submitVariant="default" + textSubmit={t("confirm")} + title={t("title", { name: singular })} + /> + ); +}; diff --git a/packages/vitnode/src/views/admin/views/content/actions/form-dialog.tsx b/packages/vitnode/src/views/admin/views/content/actions/form-dialog.tsx index d131fbf5af..0da695c1e0 100644 --- a/packages/vitnode/src/views/admin/views/content/actions/form-dialog.tsx +++ b/packages/vitnode/src/views/admin/views/content/actions/form-dialog.tsx @@ -13,19 +13,23 @@ export const ContentFormDialog = ({ children, description, form, + onOpenChange, + open, skeleton, title, }: { - /** The control that opens the dialog. */ - children: React.ReactElement; + /** The control that opens the dialog. Absent when `open` drives it. */ + children?: React.ReactElement; description: React.ReactNode; /** The form itself. Not rendered until the dialog opens. */ form: React.ReactNode; + onOpenChange?: (open: boolean) => void; + open?: boolean; skeleton: React.ReactNode; title: React.ReactNode; }) => ( - - + + {children ? : null} diff --git a/packages/vitnode/src/views/admin/views/content/actions/row-actions-model.test.ts b/packages/vitnode/src/views/admin/views/content/actions/row-actions-model.test.ts index 8083595bf6..d793d85b81 100644 --- a/packages/vitnode/src/views/admin/views/content/actions/row-actions-model.test.ts +++ b/packages/vitnode/src/views/admin/views/content/actions/row-actions-model.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest"; import type { ContentRowActionInput } from "./row-actions-model"; import { + CONTENT_EDITORIAL_ACTION_IDS, CONTENT_ROW_ACTION_IDS, CONTENT_ROW_INLINE_ACTION_LIMIT, contentRowActionIds, @@ -130,6 +131,69 @@ describe("contentRowActionIds", () => { }); }); +describe("duplicate and visibility", () => { + const widest: ContentRowActionInput = { + ...everything, + canCreate: true, + canHide: true, + duplication: true, + visibility: true, + }; + + it("offers both before delete when the content type enables them", () => { + expect(contentRowActionIds(widest)).toEqual([ + "preview", + "schedule", + "history", + "delivery", + "duplicate", + "visibility", + "delete", + ]); + }); + + it("offers neither for a content type that enabled neither", () => { + expect( + contentRowActionIds({ ...widest, duplication: false, visibility: false }), + ).toEqual(contentRowActionIds(everything)); + }); + + it("needs can_create and can_view to duplicate, like the route", () => { + expect(contentRowActionIds({ ...widest, canCreate: false })).not.toContain( + "duplicate", + ); + expect(contentRowActionIds({ ...widest, canView: false })).not.toContain( + "duplicate", + ); + expect( + contentRowActionIds({ + ...nothing, + canCreate: true, + canView: true, + duplication: true, + }), + ).toEqual(["duplicate"]); + }); + + it("gates hiding on can_hide alone - not on can_publish", () => { + expect(contentRowActionIds({ ...widest, canHide: false })).not.toContain( + "visibility", + ); + expect( + contentRowActionIds({ ...nothing, canHide: true, visibility: true }), + ).toEqual(["visibility"]); + }); + + it("keeps them out of the editorial panels a host registers", () => { + expect(CONTENT_EDITORIAL_ACTION_IDS).toEqual([ + "preview", + "schedule", + "history", + "delivery", + ]); + }); +}); + describe("contentRowActionsAreInline", () => { it("renders up to the limit as buttons", () => { expect(contentRowActionsAreInline(1)).toBe(true); diff --git a/packages/vitnode/src/views/admin/views/content/actions/row-actions-model.ts b/packages/vitnode/src/views/admin/views/content/actions/row-actions-model.ts index 2f559940e4..bf5c13ee3b 100644 --- a/packages/vitnode/src/views/admin/views/content/actions/row-actions-model.ts +++ b/packages/vitnode/src/views/admin/views/content/actions/row-actions-model.ts @@ -5,16 +5,34 @@ export const CONTENT_ROW_ACTION_IDS = [ "schedule", "history", "delivery", + "duplicate", + "visibility", "delete", ] as const; export type ContentRowActionId = (typeof CONTENT_ROW_ACTION_IDS)[number]; +/** + * The actions the list implements itself, each behind its own confirmation - + * everything else is an editorial panel a host registers. + */ +export const CONTENT_LIST_ACTION_IDS = [ + "duplicate", + "visibility", + "delete", +] as const satisfies readonly ContentRowActionId[]; + +export type ContentListActionId = (typeof CONTENT_LIST_ACTION_IDS)[number]; + /** The four editorial ones - everything the list itself does not implement. */ -export type ContentEditorialActionId = Exclude; +export type ContentEditorialActionId = Exclude< + ContentRowActionId, + ContentListActionId +>; export const CONTENT_EDITORIAL_ACTION_IDS = CONTENT_ROW_ACTION_IDS.filter( - (id): id is ContentEditorialActionId => id !== "delete", + (id): id is ContentEditorialActionId => + !(CONTENT_LIST_ACTION_IDS as readonly string[]).includes(id), ); /** Whether an action needs a confirmation and a destructive button. */ @@ -23,11 +41,17 @@ export const isDestructiveContentRowAction = ( ): boolean => id === "delete"; export interface ContentRowActionInput { + /** `can_create` - a copy is a new record. */ + canCreate?: boolean; canDelete: boolean; + /** `can_hide` - hiding and unhiding, and nothing else. */ + canHide?: boolean; canPublish: boolean; canView: boolean; /** `definition.delivery.enabled` - the canonical path and URL history. */ delivery: boolean; + /** `definition.duplication.enabled` - draft copies. */ + duplication?: boolean; /** `definition.editorial.enabled` - revisions, and therefore history. */ editorial: boolean; /** `definition.editorial.preview.enabled` - signed draft links. */ @@ -36,17 +60,31 @@ export interface ContentRowActionInput { renderable?: readonly ContentRowActionId[]; /** `definition.editorial.scheduling.enabled` - publish/unpublish later. */ scheduling: boolean; + /** `definition.visibility.enabled` - hiding a record without unpublishing it. */ + visibility?: boolean; } const gateOf = ( id: ContentRowActionId, - { canDelete, canPublish, canView }: ContentRowActionInput, + { + canCreate = false, + canDelete, + canHide = false, + canPublish, + canView, + }: ContentRowActionInput, ): boolean => { switch (id) { case "delete": return canDelete; + // The route checks both: creating is what it does, reading the source is + // what it needs. + case "duplicate": + return canCreate && canView; case "schedule": return canPublish; + case "visibility": + return canHide; default: return canView; } @@ -54,19 +92,30 @@ const gateOf = ( const featureOf = ( id: ContentRowActionId, - { delivery, editorial, preview, scheduling }: ContentRowActionInput, + { + delivery, + duplication = false, + editorial, + preview, + scheduling, + visibility = false, + }: ContentRowActionInput, ): boolean => { switch (id) { case "delete": return true; case "delivery": return delivery; + case "duplicate": + return duplication; case "history": return editorial; case "preview": return preview; case "schedule": return scheduling; + case "visibility": + return visibility; } }; diff --git a/packages/vitnode/src/views/admin/views/content/actions/row-dialogs.test.tsx b/packages/vitnode/src/views/admin/views/content/actions/row-dialogs.test.tsx new file mode 100644 index 0000000000..4ad5c24b96 --- /dev/null +++ b/packages/vitnode/src/views/admin/views/content/actions/row-dialogs.test.tsx @@ -0,0 +1,157 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { IntlProvider } from "use-intl"; +import { describe, expect, it, vi } from "vitest"; + +import { ContentDuplicateDialog } from "./duplicate-dialog"; +import { ContentVisibilityDialog } from "./visibility-dialog"; + +const wrapper = ({ children }: { children: React.ReactNode }) => ( + + {children} + +); + +const renderVisibility = (action: "hide" | "unhide", confirmed = true) => { + const onConfirm = vi.fn(async () => Promise.resolve(confirmed)); + + render( + + + , + { wrapper }, + ); + + fireEvent.click(screen.getByRole("button", { name: "open" })); + + return { onConfirm }; +}; + +describe("ContentVisibilityDialog", () => { + it("explains what hiding does before it hides anything", async () => { + const { onConfirm } = renderVisibility("hide"); + + expect(await screen.findByText("core.content.hide.title")).toBeTruthy(); + expect(screen.getByText("core.content.hide.desc")).toBeTruthy(); + expect(onConfirm).not.toHaveBeenCalled(); + + fireEvent.click( + screen.getByRole("button", { name: "core.content.hide.confirm" }), + ); + + await waitFor(() => { + expect(onConfirm).toHaveBeenCalledTimes(1); + }); + await waitFor(() => { + expect(screen.queryByText("core.content.hide.title")).toBeNull(); + }); + }); + + it("asks to unhide a hidden record with its own words", async () => { + const { onConfirm } = renderVisibility("unhide"); + + expect(await screen.findByText("core.content.unhide.desc")).toBeTruthy(); + + fireEvent.click( + screen.getByRole("button", { name: "core.content.unhide.confirm" }), + ); + + await waitFor(() => { + expect(onConfirm).toHaveBeenCalledTimes(1); + }); + }); + + it("stays open when the write failed, so the editor can try again", async () => { + const { onConfirm } = renderVisibility("hide", false); + + fireEvent.click( + await screen.findByRole("button", { name: "core.content.hide.confirm" }), + ); + + await waitFor(() => { + expect(onConfirm).toHaveBeenCalledTimes(1); + }); + expect(screen.getByText("core.content.hide.title")).toBeTruthy(); + }); + + it("does nothing when cancelled", async () => { + const { onConfirm } = renderVisibility("hide"); + + fireEvent.click( + await screen.findByRole("button", { + name: "core.global.confirm_action.cancel", + }), + ); + + await waitFor(() => { + expect(screen.queryByText("core.content.hide.title")).toBeNull(); + }); + expect(onConfirm).not.toHaveBeenCalled(); + }); +}); + +describe("ContentDuplicateDialog", () => { + it("copies only after the confirmation", async () => { + const onConfirm = vi.fn(async () => Promise.resolve(true)); + const onOpenChange = vi.fn(); + + render( + , + { wrapper }, + ); + + expect( + await screen.findByText("core.content.duplicate.title"), + ).toBeTruthy(); + expect(screen.getByText("core.content.duplicate.desc")).toBeTruthy(); + expect(onConfirm).not.toHaveBeenCalled(); + + fireEvent.click( + screen.getByRole("button", { name: "core.content.duplicate.confirm" }), + ); + + await waitFor(() => { + expect(onConfirm).toHaveBeenCalledTimes(1); + }); + await waitFor(() => { + expect(onOpenChange).toHaveBeenCalledWith(false); + }); + }); + + it("keeps the dialog open when the copy was refused", async () => { + const onConfirm = vi.fn(async () => Promise.resolve(false)); + const onOpenChange = vi.fn(); + + render( + , + { wrapper }, + ); + + fireEvent.click( + await screen.findByRole("button", { + name: "core.content.duplicate.confirm", + }), + ); + + await waitFor(() => { + expect(onConfirm).toHaveBeenCalledTimes(1); + }); + expect(onOpenChange).not.toHaveBeenCalledWith(false); + }); +}); diff --git a/packages/vitnode/src/views/admin/views/content/actions/visibility-dialog.tsx b/packages/vitnode/src/views/admin/views/content/actions/visibility-dialog.tsx new file mode 100644 index 0000000000..43f54ce78b --- /dev/null +++ b/packages/vitnode/src/views/admin/views/content/actions/visibility-dialog.tsx @@ -0,0 +1,70 @@ +import { EyeClosedIcon, ViewIcon } from "lucide-react"; +import { useTranslations } from "use-intl"; + +import type { ContentVisibilityAction } from "@/content/visibility"; + +import { ConfirmActionAlertDialog } from "@/components/confirm-action/confirm-action-alert-dialog"; + +/** The icon each visibility action wears - in the row, the menu and the editor. */ +export const CONTENT_VISIBILITY_ICONS: Record< + ContentVisibilityAction, + React.ReactNode +> = { + hide: , + unhide: , +}; + +/** + * "Hide this record?" / "Unhide this record?", with what each one does and + * does not do to the public site. + * + * Hiding is the destructive-looking one: it takes a record off the public site + * on the spot, although it unpublishes nothing. Unhiding says that it restores + * public access only to a record that is published. + * + * `onConfirm` performs the write and reports back - `true` closes the dialog, + * `false` keeps it open for another try. The toasts are the caller's, which knows + * whether it is a list row or an open editor. + */ +export const ContentVisibilityDialog = ({ + action, + children, + finalFocus, + onConfirm, + onOpenChange, + open, + singular, + title, +}: { + action: ContentVisibilityAction; + /** The trigger, when the dialog opens itself. */ + children?: React.ReactElement; + finalFocus?: React.RefObject; + onConfirm: () => Promise; + onOpenChange?: (open: boolean) => void; + open?: boolean; + singular: string; + title: string; +}) => { + const t = useTranslations("core.content"); + + return ( + {title}, + })} + finalFocus={finalFocus} + icon={CONTENT_VISIBILITY_ICONS[action]} + onOpenChange={onOpenChange} + onSubmit={async ({ onClose }) => { + if (await onConfirm()) onClose(); + }} + open={open} + submitVariant={action === "hide" ? "destructive" : "default"} + textSubmit={t(`${action}.confirm`)} + title={t(`${action}.title`, { name: singular })} + > + {children} + + ); +}; diff --git a/packages/vitnode/src/views/admin/views/content/form/context.tsx b/packages/vitnode/src/views/admin/views/content/form/context.tsx index 8c8a89e542..5b9ecf5f8f 100644 --- a/packages/vitnode/src/views/admin/views/content/form/context.tsx +++ b/packages/vitnode/src/views/admin/views/content/form/context.tsx @@ -2,6 +2,7 @@ import React from "react"; import type { PageTitleBack } from "@/components/ui/page-title"; import type { ContentFileFieldValue } from "@/content/files"; +import type { ContentVisibilityAction } from "@/content/visibility"; export interface ContentFormHeaderValue { back: PageTitleBack; @@ -39,6 +40,19 @@ export interface ContentFormContextValue { skeleton?: boolean; title?: string; translations?: readonly ContentFormTranslationMeta[]; + /** + * Record-level hiding, for a content type with `visibility` enabled. Absent + * for every other one - and from a layout rendered by an older host. + */ + visibility?: { + /** `can_hide`. */ + canHide: boolean; + enabled: boolean; + /** When the record was hidden, or `null` while it is visible. */ + hiddenAt?: unknown; + /** Hides or unhides; `true` when the dialog may close. */ + transition?: (action: ContentVisibilityAction) => Promise; + }; } const ContentFormContext = React.createContext( diff --git a/packages/vitnode/src/views/admin/views/content/form/index.ts b/packages/vitnode/src/views/admin/views/content/form/index.ts index 36b2aaaab7..c4585c2b70 100644 --- a/packages/vitnode/src/views/admin/views/content/form/index.ts +++ b/packages/vitnode/src/views/admin/views/content/form/index.ts @@ -46,3 +46,9 @@ export { export { ContentFormStatusSwitch } from "./status-switch"; export { useTranslationFreshness } from "./translation-freshness"; export { useContentFormValues, useSetContentFormValue } from "./values"; +export { + ContentFormHiddenBadge, + ContentFormHiddenNotice, + ContentFormVisibilityToggle, + useContentFormHidden, +} from "./visibility"; diff --git a/packages/vitnode/src/views/admin/views/content/form/primitives.tsx b/packages/vitnode/src/views/admin/views/content/form/primitives.tsx index f4c7c95291..682eeb8a48 100644 --- a/packages/vitnode/src/views/admin/views/content/form/primitives.tsx +++ b/packages/vitnode/src/views/admin/views/content/form/primitives.tsx @@ -15,13 +15,21 @@ import { ContentFormButtonSkeleton, ContentFormStatusSkeleton, } from "./skeleton"; +import { + ContentFormHiddenNotice, + ContentFormVisibilityToggle, + useContentFormHidden, +} from "./visibility"; export const ContentFormSubmit = ({ label, withPublicationToggle = true, + withVisibilityToggle = true, }: { label?: React.ReactNode; withPublicationToggle?: boolean; + /** The Hide / Unhide button, for a content type with visibility enabled. */ + withVisibilityToggle?: boolean; }) => { const tContent = useTranslations("core.content"); const { mode, publication, skeleton } = useContentForm(); @@ -57,6 +65,9 @@ export const ContentFormSubmit = ({ return ( <> + {mode === "edit" && withVisibilityToggle ? ( + + ) : null} {mode === "edit" && withPublicationToggle ? ( ) : null} @@ -71,6 +82,7 @@ export const ContentFormSubmit = ({ const ContentFormPublicationToggle = () => { const tContent = useTranslations("core.content"); const { publication, singular, title } = useContentForm(); + const hidden = useContentFormHidden(); const { canPublish, enabled, status, transition } = publication; if (!enabled || !canPublish || !transition) return null; @@ -78,14 +90,23 @@ const ContentFormPublicationToggle = () => { const { action, destructive: published } = contentPublicationTransition(status); const Icon = published ? EyeOffIcon : SendIcon; + const description = tContent.rich(`${action}.desc`, { + title: () => ( + {title ?? singular} + ), + }); return ( ( - {title ?? singular} - ), - })} + description={ + hidden && action === "publish" ? ( + <> + {description} {tContent("visibility.publish_note")} + + ) : ( + description + ) + } icon={} onSubmit={async ({ onClose }) => { if (await transition(action)) onClose(); @@ -131,17 +152,21 @@ export const ContentFormRemainingFields = ({ }; export const ContentFormStatus = () => { - const { mode, publication, skeleton } = useContentForm(); + const { mode, publication, skeleton, visibility } = useContentForm(); if (!publication.enabled || mode === "create") return null; if (skeleton) return ; return ( - +
+ + +
); }; @@ -151,11 +176,13 @@ export const ContentFormActions = ({ className, submitLabel, withPublicationToggle, + withVisibilityToggle, ...props }: React.ComponentProps<"div"> & { cancelHref?: string; submitLabel?: React.ReactNode; withPublicationToggle?: boolean; + withVisibilityToggle?: boolean; }) => { const t = useTranslations("core.global"); @@ -177,6 +204,7 @@ export const ContentFormActions = ({ ); diff --git a/packages/vitnode/src/views/admin/views/content/form/publication-status.tsx b/packages/vitnode/src/views/admin/views/content/form/publication-status.tsx index 6aa5792522..641c5bfd6d 100644 --- a/packages/vitnode/src/views/admin/views/content/form/publication-status.tsx +++ b/packages/vitnode/src/views/admin/views/content/form/publication-status.tsx @@ -5,14 +5,20 @@ import { DateFormat } from "@/components/date-format"; import { Badge } from "@/components/ui/badge"; import { isContentPublished } from "@/content/publication"; +import { ContentHiddenBadge } from "../lib/hidden-badge"; + export const ContentFormPublication = ({ + hiddenAt, publishedAt, status, }: { + /** The record's `hiddenAt`, for a content type with visibility enabled. */ + hiddenAt?: unknown; publishedAt: unknown; status: unknown; }) => { const t = useTranslations("core.content.status"); + const tVisibility = useTranslations("core.content.visibility"); const published = isContentPublished(status); const date = typeof publishedAt === "string" ? new Date(publishedAt) : null; @@ -27,6 +33,7 @@ export const ContentFormPublication = ({ )} {published ? t("published") : t("draft")} + {date ? : t("never_published")} diff --git a/packages/vitnode/src/views/admin/views/content/form/status-switch.tsx b/packages/vitnode/src/views/admin/views/content/form/status-switch.tsx index 23219f042c..769fef88a2 100644 --- a/packages/vitnode/src/views/admin/views/content/form/status-switch.tsx +++ b/packages/vitnode/src/views/admin/views/content/form/status-switch.tsx @@ -9,6 +9,7 @@ import { isContentPublished } from "@/content/publication"; import { useContentForm } from "./context"; import { ContentFormButtonSkeleton } from "./skeleton"; +import { ContentFormHiddenBadge, useContentFormHidden } from "./visibility"; type StatusOption = "draft" | "published"; @@ -25,6 +26,7 @@ export const ContentFormStatusSwitch = ({ const { mode, publication, singular, skeleton, title } = useContentForm(); const [pending, setPending] = React.useState(null); const buttonsRef = React.useRef<(HTMLButtonElement | null)[]>([]); + const hidden = useContentFormHidden(); if (!publication.enabled || mode === "create") return null; if (skeleton) return ; @@ -115,14 +117,23 @@ export const ContentFormStatusSwitch = ({ + + ( - - {title ?? singular} - - ), - })} + description={ + <> + {tContent.rich(`${action}.desc`, { + title: () => ( + + {title ?? singular} + + ), + })} + {hidden && action === "publish" + ? ` ${tContent("visibility.publish_note")}` + : null} + + } icon={action === "publish" ? : } onOpenChange={open => { if (!open) setPending(null); diff --git a/packages/vitnode/src/views/admin/views/content/form/transport.tsx b/packages/vitnode/src/views/admin/views/content/form/transport.tsx index caf642a149..6f143f7831 100644 --- a/packages/vitnode/src/views/admin/views/content/form/transport.tsx +++ b/packages/vitnode/src/views/admin/views/content/form/transport.tsx @@ -2,6 +2,7 @@ import React from "react"; import type { ContentDrafts } from "@/content/live/http"; import type { ContentFieldLock } from "@/content/live/protocol"; +import type { ContentVisibilityAction } from "@/content/visibility"; import type { ContentMutationResult, @@ -96,6 +97,18 @@ export interface ContentFormTransport { itemId: number, draft: { locale: null | string; values: Record }, ) => Promise; + /** + * Hides or unhides a record, for a content type with visibility enabled. + * `expectedVersion` is the form's own precondition, so a record somebody + * saved meanwhile answers `409` rather than changing under the editor. + * Optional: a transport without it renders no Hide / Unhide control. + */ + setHidden?: ( + contentTypeId: string, + itemId: number, + action: ContentVisibilityAction, + expectedVersion?: number, + ) => Promise; /** Moves a record back to `draft`. Idempotent, like {@link publish}. */ unpublish: ( contentTypeId: string, diff --git a/packages/vitnode/src/views/admin/views/content/form/visibility.test.tsx b/packages/vitnode/src/views/admin/views/content/form/visibility.test.tsx new file mode 100644 index 0000000000..3e25cf7e23 --- /dev/null +++ b/packages/vitnode/src/views/admin/views/content/form/visibility.test.tsx @@ -0,0 +1,168 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { IntlProvider } from "use-intl"; +import { describe, expect, it, vi } from "vitest"; + +import { type ContentFormContextValue, ContentFormProvider } from "./context"; +import { ContentFormStatus } from "./primitives"; +import { ContentFormStatusSwitch } from "./status-switch"; +import { ContentFormVisibilityToggle } from "./visibility"; + +const wrapper = ({ children }: { children: React.ReactNode }) => ( + + {children} + +); + +const HIDDEN_AT = "2026-10-01T09:00:00.000Z"; + +const renderForm = ( + children: React.ReactNode, + { + mode = "edit", + publication = {}, + visibility = {}, + }: { + mode?: ContentFormContextValue["mode"]; + publication?: Partial; + visibility?: Partial>; + } = {}, +) => { + const transition = vi.fn(async () => Promise.resolve(true)); + const publish = vi.fn(async () => Promise.resolve(true)); + + render( + + {children} + , + { wrapper }, + ); + + return { publish, transition }; +}; + +describe("ContentFormVisibilityToggle", () => { + it("hides a visible record after the confirmation", async () => { + const { transition } = renderForm(); + + fireEvent.click( + screen.getByRole("button", { name: "core.content.actions.hide" }), + ); + + expect(await screen.findByText("core.content.hide.desc")).toBeTruthy(); + expect(transition).not.toHaveBeenCalled(); + + fireEvent.click( + screen.getByRole("button", { name: "core.content.hide.confirm" }), + ); + + await waitFor(() => { + expect(transition).toHaveBeenCalledWith("hide"); + }); + }); + + it("offers to unhide a hidden record", async () => { + const { transition } = renderForm(, { + visibility: { hiddenAt: HIDDEN_AT }, + }); + + fireEvent.click( + screen.getByRole("button", { name: "core.content.actions.unhide" }), + ); + fireEvent.click( + await screen.findByRole("button", { + name: "core.content.unhide.confirm", + }), + ); + + await waitFor(() => { + expect(transition).toHaveBeenCalledWith("unhide"); + }); + }); + + it.each([ + ["without can_hide", { visibility: { canHide: false } }], + [ + "for a content type without visibility", + { visibility: { enabled: false } }, + ], + ["while creating", { mode: "create" as const }], + ])("renders nothing %s", (_, options) => { + renderForm(, options); + + expect( + screen.queryByRole("button", { name: "core.content.actions.hide" }), + ).toBeNull(); + }); +}); + +describe("the editor's status", () => { + it("says Hidden, and why publishing will not help, for a hidden record", () => { + renderForm(, { visibility: { hiddenAt: HIDDEN_AT } }); + + expect(screen.getByText("core.content.status.published")).toBeTruthy(); + expect(screen.getByText("core.content.visibility.hidden")).toBeTruthy(); + expect( + screen.getByText("core.content.visibility.hidden_desc"), + ).toBeTruthy(); + }); + + it("says nothing about visibility for a visible record", () => { + renderForm(); + + expect(screen.queryByText("core.content.visibility.hidden")).toBeNull(); + expect( + screen.queryByText("core.content.visibility.hidden_desc"), + ).toBeNull(); + }); + + it("shows the badge beside the status switch, and warns before publishing", async () => { + const { publish } = renderForm(, { + publication: { status: "draft" }, + visibility: { hiddenAt: HIDDEN_AT }, + }); + + expect(screen.getByText("core.content.visibility.hidden")).toBeTruthy(); + + fireEvent.click( + screen.getByRole("radio", { name: "core.content.status.published" }), + ); + + expect( + await screen.findByText("core.content.visibility.publish_note", { + exact: false, + }), + ).toBeTruthy(); + + fireEvent.click( + screen.getByRole("button", { name: "core.content.publish.confirm" }), + ); + + await waitFor(() => { + expect(publish).toHaveBeenCalledWith("publish"); + }); + }); +}); diff --git a/packages/vitnode/src/views/admin/views/content/form/visibility.tsx b/packages/vitnode/src/views/admin/views/content/form/visibility.tsx new file mode 100644 index 0000000000..68ba241d2b --- /dev/null +++ b/packages/vitnode/src/views/admin/views/content/form/visibility.tsx @@ -0,0 +1,120 @@ +import { useFormatter, useTranslations } from "use-intl"; + +import { Button } from "@/components/ui/button"; +import { TooltipWithContent } from "@/components/ui/tooltip"; +import { + contentVisibilityTransition, + isContentHidden, +} from "@/content/visibility"; + +import { + CONTENT_VISIBILITY_ICONS, + ContentVisibilityDialog, +} from "../actions/visibility-dialog"; +import { ContentHiddenBadge } from "../lib/hidden-badge"; +import { useContentForm } from "./context"; + +/** Whether the record this form edits is hidden right now. */ +export const useContentFormHidden = (): boolean => { + const { mode, visibility } = useContentForm(); + + return ( + mode === "edit" && + visibility?.enabled === true && + isContentHidden({ hiddenAt: visibility.hiddenAt }) + ); +}; + +/** + * "Hidden", beside the status - with since when, for anyone who hovers it. + * Nothing for a visible record, a create, or a content type without visibility. + */ +export const ContentFormHiddenBadge = () => { + const t = useTranslations("core.content.visibility"); + const format = useFormatter(); + const { visibility } = useContentForm(); + const hidden = useContentFormHidden(); + + if (!hidden) return null; + + const since = + typeof visibility?.hiddenAt === "string" + ? new Date(visibility.hiddenAt) + : null; + const badge = ( + + ); + + if (!since || Number.isNaN(since.getTime())) return badge; + + return ( + + {badge} + + ); +}; + +/** + * What hiding means, in words, wherever the editor sees a hidden record's + * status: off the public site, and publishing alone does not bring it back. + */ +export const ContentFormHiddenNotice = () => { + const t = useTranslations("core.content.visibility"); + const hidden = useContentFormHidden(); + + if (!hidden) return null; + + return ( +

+ {t("hidden_desc")} +

+ ); +}; + +/** + * Hide or unhide the record being edited, behind the same confirmation the list + * uses. Gated by `can_hide`; absent while creating, and for a content type + * without visibility. + */ +export const ContentFormVisibilityToggle = () => { + const tActions = useTranslations("core.content.actions"); + const { mode, singular, title, visibility } = useContentForm(); + + if ( + mode !== "edit" || + !visibility?.enabled || + !visibility.canHide || + !visibility.transition + ) { + return null; + } + + const { transition } = visibility; + const { action } = contentVisibilityTransition({ + hiddenAt: visibility.hiddenAt, + }); + + return ( + await transition(action)} + singular={singular} + title={title ?? singular} + > + + + ); +}; diff --git a/packages/vitnode/src/views/admin/views/content/lib/duplicate-feedback.test.ts b/packages/vitnode/src/views/admin/views/content/lib/duplicate-feedback.test.ts new file mode 100644 index 0000000000..090e32eac6 --- /dev/null +++ b/packages/vitnode/src/views/admin/views/content/lib/duplicate-feedback.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vitest"; + +import { contentDuplicateErrorMessage } from "./duplicate-feedback"; + +const LABELS: Record = { + friendlyUrl: "Friendly URL", + isbn: "ISBN", + sku: "SKU", +}; + +const labelField = (name: string) => LABELS[name] ?? name; + +describe("contentDuplicateErrorMessage", () => { + it("names the slug field by its label, with the slug that ran out", () => { + expect( + contentDuplicateErrorMessage( + { + code: "CONTENT_DUPLICATE_SLUG_CONFLICT", + contentTypeId: "blog.post", + field: "friendlyUrl", + locale: "en", + slug: "hello-world-copy", + }, + { labelField }, + ), + ).toEqual({ + key: "slug_conflict", + values: { field: "Friendly URL", slug: "hello-world-copy" }, + }); + }); + + it("lists every unique field that needs a new value, by label", () => { + expect( + contentDuplicateErrorMessage( + { + code: "CONTENT_DUPLICATE_UNIQUE_REQUIRED", + contentTypeId: "shop.product", + fields: ["sku", "isbn"], + }, + { labelField, list: names => names.join(" and ") }, + ), + ).toEqual({ + key: "unique_required", + values: { count: 2, fields: "SKU and ISBN" }, + }); + }); + + it("leaves every other refusal to the shared error sentence", () => { + expect(contentDuplicateErrorMessage(undefined, { labelField })).toBeNull(); + }); +}); diff --git a/packages/vitnode/src/views/admin/views/content/lib/duplicate-feedback.ts b/packages/vitnode/src/views/admin/views/content/lib/duplicate-feedback.ts new file mode 100644 index 0000000000..9ae981ea48 --- /dev/null +++ b/packages/vitnode/src/views/admin/views/content/lib/duplicate-feedback.ts @@ -0,0 +1,49 @@ +import type { ContentDuplicateRejection } from "@/content/conflicts"; + +import { CONTENT_DUPLICATE_CODES } from "@/content/const"; + +/** A refused duplicate, as the sentence under `core.content.duplicate.errors`. */ +export type ContentDuplicateErrorMessage = + | { + key: "slug_conflict"; + values: { field: string; slug: string }; + } + | { + key: "unique_required"; + values: { count: number; fields: string }; + }; + +/** + * Names the field a refused copy is about, in the administrator's words. + * + * `null` for everything else - a version conflict, a delivery reservation, a + * `404` - which the shared `contentErrorKey` already has a sentence for. + */ +export const contentDuplicateErrorMessage = ( + rejection: ContentDuplicateRejection | undefined, + { + labelField, + list = names => names.join(", "), + }: { + labelField: (name: string) => string; + /** Joins the field labels the way the reader's language joins a list. */ + list?: (names: string[]) => string; + }, +): ContentDuplicateErrorMessage | null => { + if (!rejection) return null; + + if (rejection.code === CONTENT_DUPLICATE_CODES.slugConflict) { + return { + key: "slug_conflict", + values: { field: labelField(rejection.field), slug: rejection.slug }, + }; + } + + return { + key: "unique_required", + values: { + count: rejection.fields.length, + fields: list(rejection.fields.map(labelField)), + }, + }; +}; diff --git a/packages/vitnode/src/views/admin/views/content/lib/hidden-badge.tsx b/packages/vitnode/src/views/admin/views/content/lib/hidden-badge.tsx new file mode 100644 index 0000000000..2c1c5e0a84 --- /dev/null +++ b/packages/vitnode/src/views/admin/views/content/lib/hidden-badge.tsx @@ -0,0 +1,29 @@ +import { EyeClosedIcon } from "lucide-react"; + +import { Badge } from "@/components/ui/badge"; +import { isContentHidden } from "@/content/visibility"; + +/** + * "Hidden", for a record readers cannot reach whatever its status says. + * + * Words and an icon, never a colour alone: next to a "Published" badge this is + * the one thing that tells an editor the published record is not public. + * Renders nothing for a visible record - or for a content type without + * visibility, whose rows carry no `hiddenAt` at all. + */ +export const ContentHiddenBadge = ({ + label, + row, +}: { + label: string; + row: { hiddenAt?: unknown }; +}) => { + if (!isContentHidden(row)) return null; + + return ( + + + {label} + + ); +}; diff --git a/packages/vitnode/src/views/admin/views/content/page/content-form-page.tsx b/packages/vitnode/src/views/admin/views/content/page/content-form-page.tsx index b673a32698..a4d55d1439 100644 --- a/packages/vitnode/src/views/admin/views/content/page/content-form-page.tsx +++ b/packages/vitnode/src/views/admin/views/content/page/content-form-page.tsx @@ -27,6 +27,7 @@ export interface ContentFormPageProps { spec: ContentFormSpec; title?: string; translations?: readonly TranslationRow[]; + visibility?: boolean; } export const ContentFormPage = ({ diff --git a/packages/vitnode/src/views/admin/views/content/table/cells.test.tsx b/packages/vitnode/src/views/admin/views/content/table/cells.test.tsx index 039830dcbf..0f9eb9e864 100644 --- a/packages/vitnode/src/views/admin/views/content/table/cells.test.tsx +++ b/packages/vitnode/src/views/admin/views/content/table/cells.test.tsx @@ -138,4 +138,54 @@ describe("ContentCell", () => { expect(screen.getByText(COVER.name)).toBeTruthy(); }); + + describe("status with visibility", () => { + const STATUS: ContentColumnSpec = { + kind: "publication", + label: "Status", + name: "status", + }; + const labels = { draft: "Draft", hidden: "Hidden", published: "Published" }; + + it("says Hidden in words beside a published record readers cannot reach", () => { + render( + , + ); + + expect(screen.getByText("Published")).toBeTruthy(); + expect(screen.getByText("Hidden")).toBeTruthy(); + }); + + it("shows no badge for a visible record", () => { + render( + , + ); + + expect(screen.getByText("Published")).toBeTruthy(); + expect(screen.queryByText("Hidden")).toBeNull(); + }); + + it("shows no badge for a content type without visibility", () => { + cell( + STATUS, + rowWith({ hiddenAt: "2026-10-01T09:00:00.000Z", status: "draft" }), + ); + + expect(screen.getByText("Draft")).toBeTruthy(); + expect(screen.queryByText("Hidden")).toBeNull(); + }); + }); }); diff --git a/packages/vitnode/src/views/admin/views/content/table/cells.tsx b/packages/vitnode/src/views/admin/views/content/table/cells.tsx index 88121ae72e..2b83a94872 100644 --- a/packages/vitnode/src/views/admin/views/content/table/cells.tsx +++ b/packages/vitnode/src/views/admin/views/content/table/cells.tsx @@ -22,6 +22,8 @@ import { isContentPublished } from "@/content/publication"; import { isRichTextDocument } from "@/content/rich-text/document"; import { richTextToPlainText } from "@/content/rich-text/plain-text"; +import { ContentHiddenBadge } from "../lib/hidden-badge"; + export interface ContentRowData extends Record { files?: Record; id: number; @@ -149,11 +151,19 @@ const ContentReferenceList = ({ ); }; +/** The publication badge's words, and the visibility badge's beside it. */ +export interface ContentStatusLabels { + draft: string; + /** `core.content.visibility.hidden`. Absent, a hidden row shows no badge. */ + hidden?: string; + published: string; +} + interface ContentCellProps { emptyLabel: string; row: ContentRowData; spec: ContentColumnSpec; - statusLabels: { draft: string; published: string }; + statusLabels: ContentStatusLabels; } const ContentCellValue = ({ @@ -225,8 +235,7 @@ const ContentCellValue = ({ case "publication": { const published = isContentPublished(value); - - return ( + const badge = ( {published ? ( @@ -236,6 +245,19 @@ const ContentCellValue = ({ {published ? statusLabels.published : statusLabels.draft} ); + + // "Published" alone would be a lie for a hidden record: readers get a 404. + return statusLabels.hidden === undefined ? ( + badge + ) : ( + + {badge} + + + ); } case "richText": diff --git a/packages/vitnode/src/views/admin/views/content/table/columns.tsx b/packages/vitnode/src/views/admin/views/content/table/columns.tsx index e1b77d3833..102ff6f8d2 100644 --- a/packages/vitnode/src/views/admin/views/content/table/columns.tsx +++ b/packages/vitnode/src/views/admin/views/content/table/columns.tsx @@ -5,7 +5,7 @@ import type { ContentTypeFrontendRegistration } from "@/lib/plugin"; import { orderableColumns } from "@/content/registry"; -import type { ContentRowData } from "./cells"; +import type { ContentRowData, ContentStatusLabels } from "./cells"; import { ContentCell } from "./cells"; @@ -66,8 +66,8 @@ export const contentRowTitle = ( export interface ContentCellLabels { /** `core.content.table.empty_value` - a column this row has no value for. */ empty: string; - /** `core.content.status.*`, for the publication badge. */ - status: { draft: string; published: string }; + /** `core.content.status.*`, for the publication badge - and "Hidden" beside it. */ + status: ContentStatusLabels; } export interface ContentTableColumnsArgs { diff --git a/packages/vitnode/src/views/admin/views/content/table/list-filters.test.ts b/packages/vitnode/src/views/admin/views/content/table/list-filters.test.ts new file mode 100644 index 0000000000..97fe81b99a --- /dev/null +++ b/packages/vitnode/src/views/admin/views/content/table/list-filters.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it } from "vitest"; + +import { nextFilterSelection } from "@/components/table/filters"; +import { defineContentType } from "@/content/define"; +import { field } from "@/content/fields"; + +import { + contentListSystemFilterValue, + contentListToolbarFilters, +} from "./list-filters"; + +const LABELS = { + all: "All", + status: { draft: "Draft", label: "Status", published: "Published" }, + visibility: { hidden: "Hidden", label: "Visibility", visible: "Visible" }, +}; + +const fields = { title: field.text({ required: true }) }; + +const articles = defineContentType({ + id: "blog.post", + tableName: "blog_posts", + publication: true, + visibility: true, + fields, +}); + +const pages = defineContentType({ + id: "site.page", + tableName: "site_pages", + publication: true, + fields, +}); + +const notes = defineContentType({ + id: "site.note", + tableName: "site_notes", + fields, +}); + +describe("contentListToolbarFilters", () => { + it("offers status and visibility side by side, each one value at a time", () => { + const filters = contentListToolbarFilters(articles, LABELS); + + expect(filters.map(filter => filter.id)).toEqual(["status", "visibility"]); + expect(filters[1]).toEqual({ + id: "visibility", + label: "Visibility", + options: [ + { label: "Visible", value: "visible" }, + { label: "Hidden", value: "hidden" }, + ], + single: { allLabel: "All" }, + }); + }); + + it("offers status alone for a content type that does not hide", () => { + expect( + contentListToolbarFilters(pages, LABELS).map(filter => filter.id), + ).toEqual(["status"]); + }); + + it("offers nothing for a content type without publication", () => { + expect(contentListToolbarFilters(notes, LABELS)).toEqual([]); + }); +}); + +describe("contentListSystemFilterValue", () => { + it("keeps exactly one allowed value", () => { + expect(contentListSystemFilterValue("visibility", "hidden")).toBe("hidden"); + expect(contentListSystemFilterValue("visibility", "visible")).toBe( + "visible", + ); + expect(contentListSystemFilterValue("status", "draft")).toBe("draft"); + }); + + it("reads anything else as all, instead of a 400 from the API", () => { + expect( + contentListSystemFilterValue("visibility", "hidden,visible"), + ).toBeUndefined(); + expect( + contentListSystemFilterValue("visibility", "secret"), + ).toBeUndefined(); + expect(contentListSystemFilterValue("status", "archived")).toBeUndefined(); + }); + + it("leaves a declared field's filter to the API", () => { + expect(contentListSystemFilterValue("categoryId", "7")).toBe("7"); + }); +}); + +describe("nextFilterSelection", () => { + it("replaces the value of a single-value filter, and clears it on a second pick", () => { + expect(nextFilterSelection([], "hidden", true)).toEqual(["hidden"]); + expect(nextFilterSelection(["hidden"], "visible", true)).toEqual([ + "visible", + ]); + expect(nextFilterSelection(["hidden"], "hidden", true)).toEqual([]); + }); + + it("toggles one value of a multi-value filter, keeping the rest", () => { + expect(nextFilterSelection(["a"], "b")).toEqual(["a", "b"]); + expect(nextFilterSelection(["a", "b"], "a")).toEqual(["b"]); + }); +}); diff --git a/packages/vitnode/src/views/admin/views/content/table/list-filters.ts b/packages/vitnode/src/views/admin/views/content/table/list-filters.ts new file mode 100644 index 0000000000..b9365c42db --- /dev/null +++ b/packages/vitnode/src/views/admin/views/content/table/list-filters.ts @@ -0,0 +1,82 @@ +import type { FilterDataTable } from "@/components/table/filters"; +import type { AnyContentTypeDefinition } from "@/content/types"; + +import { + CONTENT_PUBLICATION_STATUSES, + CONTENT_VISIBILITY_FILTERS, +} from "@/content/const"; + +/** + * The list filters that are not declared fields: the publication `status` and + * the record's `visibility`. Each is one equality the API reads as a single + * value, so each accepts exactly these and nothing else. + */ +export const CONTENT_LIST_SYSTEM_FILTERS: Readonly< + Record +> = { + status: CONTENT_PUBLICATION_STATUSES, + visibility: CONTENT_VISIBILITY_FILTERS, +}; + +/** + * The value a system filter's URL parameter is asking for, or `undefined` for + * "all". + * + * A hand-typed `?visibility=hidden,visible` or `?status=archived` would be a + * `400` from the API and an error screen instead of a list, so anything that is + * not exactly one allowed value reads as no filter at all. + */ +export const contentListSystemFilterValue = ( + name: string, + raw: string, +): string | undefined => { + const allowed = CONTENT_LIST_SYSTEM_FILTERS[name]; + + if (!allowed) return raw; + + return allowed.includes(raw) ? raw : undefined; +}; + +export interface ContentListFilterLabels { + /** The option that clears a filter - `core.content.filters.all`. */ + all: string; + status: { draft: string; label: string; published: string }; + visibility: { hidden: string; label: string; visible: string }; +} + +/** + * The toolbar's filters for one content type: status when it publishes, + * visibility when it hides. They compose - "published and hidden" is the set of + * records the AdminCP calls published and readers cannot reach. + */ +export const contentListToolbarFilters = ( + definition: Pick, + labels: ContentListFilterLabels, +): FilterDataTable[] => [ + ...(definition.publication.enabled + ? [ + { + id: "status", + label: labels.status.label, + options: CONTENT_PUBLICATION_STATUSES.map(value => ({ + label: labels.status[value], + value, + })), + single: { allLabel: labels.all }, + }, + ] + : []), + ...(definition.visibility.enabled + ? [ + { + id: "visibility", + label: labels.visibility.label, + options: (["visible", "hidden"] as const).map(value => ({ + label: labels.visibility[value], + value, + })), + single: { allLabel: labels.all }, + }, + ] + : []), +]; diff --git a/plugins/blog/src/api/lib/events.ts b/plugins/blog/src/api/lib/events.ts index b01622b2bf..5ba3c176d5 100644 --- a/plugins/blog/src/api/lib/events.ts +++ b/plugins/blog/src/api/lib/events.ts @@ -42,6 +42,17 @@ declare module "@vitnode/core/api/models/events" { } const POST = blogPostContentType.id; + +// What is deliberately not bridged: +// - A duplicate is a create - `content.blog.post.created`, with +// `duplicatedFromId` - so it re-emits `blog.post.created` exactly once, as a +// new article should. `content.blog.post.duplicated` carries nothing a legacy +// listener knows how to use, and re-emitting it would announce the article +// twice. +// - Hiding and unhiding change who can read an article, not the article. +// Neither is an update or a delete, and mapping them onto one would have a +// legacy indexer drop or re-add a record the engine's own search sync +// already handled. Listen to `content.blog.post.hidden` / `.unhidden`. const CATEGORY = "blog.category"; const categoryOf = async ( diff --git a/plugins/blog/src/content/post.ts b/plugins/blog/src/content/post.ts index fe3baf55b3..83775557d2 100644 --- a/plugins/blog/src/content/post.ts +++ b/plugins/blog/src/content/post.ts @@ -14,6 +14,14 @@ export const blogPostContentType = defineContentType({ publication: true, + // "Duplicate" in the article list: a draft copy with every translation, the + // title suffixed with a localized "(Copy)" and fresh per-language slugs. + duplication: true, + + // Take a published article off the site - list, page, search, sitemap - + // without unpublishing it, and put it back with one click. + visibility: true, + editorial: { revisions: { retention: 20 }, preview: { expiresInMinutes: 30 }, diff --git a/plugins/blog/src/locales/en.json b/plugins/blog/src/locales/en.json index d70bf11375..509e7a86a8 100644 --- a/plugins/blog/src/locales/en.json +++ b/plugins/blog/src/locales/en.json @@ -185,6 +185,7 @@ "@vitnode/blog:posts:can_delete": "Delete articles", "@vitnode/blog:posts:can_publish": "Publish and unpublish articles", "@vitnode/blog:posts:can_restore": "Restore an earlier version of an article", + "@vitnode/blog:posts:can_hide": "Hide and unhide articles", "@vitnode/blog:categories": "Categories", "@vitnode/blog:categories:can_view": "View categories list", "@vitnode/blog:categories:can_create": "Create categories", diff --git a/plugins/blog/src/views/admin/article/editor/article-editor.tsx b/plugins/blog/src/views/admin/article/editor/article-editor.tsx index 6a162268f7..712e0b17ae 100644 --- a/plugins/blog/src/views/admin/article/editor/article-editor.tsx +++ b/plugins/blog/src/views/admin/article/editor/article-editor.tsx @@ -18,6 +18,7 @@ import { import { ContentFormActions, ContentFormField, + ContentFormHiddenNotice, ContentFormStatusSwitch, ContentLivePresence, ContentLiveStatus, @@ -476,6 +477,7 @@ export const ArticleEditor = ({ !target && "max-w-3xl", )} > +
From 123ffe6142e305642fc0ef89cb5bbdb2bb8a6aaa Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 08:59:17 +0000 Subject: [PATCH 05/17] =?UTF-8?q?feat(content):=20=E2=9C=A8=20configurable?= =?UTF-8?q?=20id=20strategy=20(serial,=20uuid,=20bigint)=20in=20the=20engi?= =?UTF-8?q?ne=20core?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Thread ContentIdOf through types, schemas, services, routes, effects, search, delivery, previews, schedules and the AdminCP; key the shared revisions/schedules/slug-history/search tables by contentIdKey. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VpG3D33ZqXEYjN2N6eNcMK --- .../src/api/adapters/search/postgres.ts | 6 +- .../vitnode/src/api/lib/pagination-cursor.ts | 30 ++- .../vitnode/src/api/lib/with-pagination.ts | 5 +- packages/vitnode/src/api/models/search.ts | 54 +++- .../helpers/execute-content-schedule.ts | 29 ++- .../api/modules/search/routes/search.route.ts | 3 +- .../vitnode/src/components/table/content.tsx | 9 +- .../components/table/data-table-content.tsx | 8 +- .../src/components/table/expansion.tsx | 14 +- .../src/components/table/reorder-state.ts | 14 +- .../vitnode/src/components/table/reorder.tsx | 33 ++- .../src/components/table/selection.tsx | 16 +- packages/vitnode/src/content/admin/route.ts | 33 ++- packages/vitnode/src/content/admin/spec.ts | 46 +++- packages/vitnode/src/content/cache.ts | 16 +- packages/vitnode/src/content/conflicts.ts | 21 +- packages/vitnode/src/content/const.ts | 8 + packages/vitnode/src/content/define.ts | 1 + packages/vitnode/src/content/errors.ts | 25 +- packages/vitnode/src/content/events.ts | 87 ++++--- packages/vitnode/src/content/fields.ts | 23 +- packages/vitnode/src/content/ids.ts | 74 ++++++ packages/vitnode/src/content/index.ts | 9 + packages/vitnode/src/content/registry.ts | 12 +- packages/vitnode/src/content/revisions.ts | 11 +- packages/vitnode/src/content/schemas.ts | 246 +++++++++++++++--- packages/vitnode/src/content/search.ts | 8 +- .../src/content/server/advanced-store.ts | 217 ++++++++++----- .../src/content/server/advanced-tables.ts | 62 ++++- .../src/content/server/collection-api.ts | 192 +++++++++----- .../src/content/server/column-builders.ts | 66 ++++- .../src/content/server/delivery-alternates.ts | 21 +- .../src/content/server/delivery-effects.ts | 3 +- .../src/content/server/delivery-routes.ts | 27 +- .../src/content/server/delivery-service.ts | 34 ++- .../src/content/server/delivery-sitemap.ts | 26 +- .../src/content/server/delivery-writes.ts | 5 +- .../src/content/server/duplicate-effects.ts | 7 +- .../vitnode/src/content/server/duplicate.ts | 24 +- .../src/content/server/editorial-effects.ts | 38 ++- .../src/content/server/editorial-service.ts | 147 +++++++---- .../vitnode/src/content/server/effects-log.ts | 3 +- packages/vitnode/src/content/server/emit.ts | 16 +- .../vitnode/src/content/server/http-errors.ts | 5 +- .../src/content/server/list-references.ts | 79 ++++-- .../content/server/localized-admin-routes.ts | 20 +- packages/vitnode/src/content/server/model.ts | 8 +- .../src/content/server/preview-target.ts | 5 +- .../src/content/server/preview-token.ts | 22 +- .../src/content/server/public-locales.ts | 3 +- .../src/content/server/public-routes.ts | 5 +- .../content/server/public-row-hydration.ts | 11 +- .../src/content/server/public-service.ts | 25 +- packages/vitnode/src/content/server/query.ts | 7 +- .../src/content/server/revision-snapshot.ts | 27 +- .../content/server/revisions-model.test.ts | 5 +- .../src/content/server/revisions-model.ts | 16 +- .../src/content/server/route-helpers.ts | 41 ++- packages/vitnode/src/content/server/routes.ts | 219 +++++++++++----- .../src/content/server/schedule-effects.ts | 38 ++- .../src/content/server/schedules-model.ts | 25 +- .../src/content/server/search-document.ts | 5 +- .../src/content/server/search-indexer.ts | 65 ++++- .../vitnode/src/content/server/search-sync.ts | 22 +- .../vitnode/src/content/server/service.ts | 153 +++++++---- .../src/content/server/slug-history-model.ts | 73 ++++-- packages/vitnode/src/content/server/table.ts | 101 ++++++- .../translation-editorial-service.test.ts | 3 +- .../server/translation-editorial-service.ts | 21 +- .../content/server/translation-http-errors.ts | 3 +- .../src/content/server/translation-model.ts | 35 +-- .../src/content/server/translation-routes.ts | 47 ++-- .../src/content/server/translation-table.ts | 1 + packages/vitnode/src/content/server/types.ts | 149 ++++++++--- .../src/content/server/visibility-effects.ts | 3 +- packages/vitnode/src/content/sitemap.ts | 14 +- packages/vitnode/src/content/types.ts | 162 +++++++++--- packages/vitnode/src/database/content.ts | 7 +- packages/vitnode/src/database/search.ts | 4 +- packages/vitnode/src/lib/plugin.ts | 7 +- .../tanstack/admin/content/bulk-actions.tsx | 7 +- .../tanstack/admin/content/form/page-body.tsx | 8 +- .../tanstack/admin/content/form/screen.tsx | 2 +- .../tanstack/admin/content/form/transport.ts | 3 +- .../src/tanstack/admin/content/query.ts | 4 +- .../src/tanstack/admin/content/route.tsx | 5 +- .../src/tanstack/admin/content/slots.ts | 3 +- packages/vitnode/src/tests/memory-db.ts | 34 ++- packages/vitnode/src/tests/revisions-table.ts | 9 +- .../views/content/actions/content-form.tsx | 5 +- .../views/content/actions/delivery-action.tsx | 4 +- .../actions/delivery/delivery-panel.tsx | 4 +- .../views/content/actions/editorial-api.ts | 17 +- .../views/content/actions/editorial-query.ts | 15 +- .../content/actions/editorial-transport.tsx | 19 +- .../views/content/actions/history-action.tsx | 3 +- .../content/actions/history/revision-diff.tsx | 7 +- .../actions/history/revision-history.tsx | 3 +- .../content/actions/history/revision-row.tsx | 7 +- .../views/content/actions/preview-action.tsx | 6 +- .../views/content/actions/schedule-action.tsx | 4 +- .../actions/schedule/schedule-panel.tsx | 3 +- .../admin/views/content/content-mutation.ts | 7 +- .../admin/views/content/content-query.ts | 18 +- .../views/content/form/contract.test-d.ts | 2 +- .../admin/views/content/form/item-query.ts | 5 +- .../admin/views/content/form/mutations-api.ts | 17 +- .../admin/views/content/form/transport.tsx | 15 +- .../views/content/lib/field-component.tsx | 4 +- .../admin/views/content/lib/invalidate.ts | 14 +- .../views/content/lib/reference-options.ts | 45 ++-- .../views/content/lib/relation-set-field.tsx | 33 ++- .../views/content/page/content-form-page.tsx | 9 +- .../views/content/table/bulk-actions-model.ts | 22 +- .../views/admin/views/content/table/cells.tsx | 3 +- .../views/content/table/list-mutations.ts | 8 +- .../files/actions/files-bulk-actions.tsx | 6 +- .../files/actions/files-bulk-actions.tsx | 6 +- packages/vitnode/src/views/search/types.ts | 2 +- 119 files changed, 2584 insertions(+), 969 deletions(-) diff --git a/packages/vitnode/src/api/adapters/search/postgres.ts b/packages/vitnode/src/api/adapters/search/postgres.ts index 15530d7448..fcba37ab9e 100644 --- a/packages/vitnode/src/api/adapters/search/postgres.ts +++ b/packages/vitnode/src/api/adapters/search/postgres.ts @@ -25,9 +25,11 @@ import type { SearchQueryParams, SearchResult, } from "@/api/models/search"; +import type { RegisteredContentType } from "@/content/registry"; import { parseSearchOffset } from "@/api/lib/search-offset"; import { storageUrlOf } from "@/api/lib/storage-url"; +import { searchItemIdFromKey } from "@/api/models/search"; import { core_files } from "@/database/files"; import { core_search_index, resolveSearchTextConfig } from "@/database/search"; import { core_users } from "@/database/users"; @@ -243,11 +245,13 @@ export const PostgresSearchAdapter = (): SearchProviderApiPlugin => ({ const hasNextPage = rows.length > size; const sliced = rows.slice(0, size); + const contentTypes: RegisteredContentType[] | undefined = + c.get("core").contentTypes; const edges: SearchHit[] = sliced.map(row => ({ id: row.id, pluginId: row.pluginId, itemType: row.itemType, - itemId: row.itemId, + itemId: searchItemIdFromKey(contentTypes, row.itemType, row.itemId), languageCode: row.languageCode, authorId: row.authorId, title: row.title, diff --git a/packages/vitnode/src/api/lib/pagination-cursor.ts b/packages/vitnode/src/api/lib/pagination-cursor.ts index ebe1d80e3c..3b1c5a6296 100644 --- a/packages/vitnode/src/api/lib/pagination-cursor.ts +++ b/packages/vitnode/src/api/lib/pagination-cursor.ts @@ -39,6 +39,15 @@ const baseDataTypeOf = (column: PgColumn): string => const isUuidColumn = (column: PgColumn): boolean => column.getSQLType().toLowerCase() === "uuid"; +/** + * A Postgres `bigint` read in Drizzle's `string` mode - how a content type with + * `idStrategy: "bigint"` keys its rows. Its data type says `string`, but it + * orders and compares as the 64-bit integer it is, so it is a `bigint` cursor + * kind whose JavaScript values are decimal strings. + */ +const isStringInt64Column = (column: PgColumn): boolean => + column.dataType === "string int64"; + const isArrayColumn = (column: PgColumn): boolean => column.dimensions > 0; const badRequest = (message: string): HTTPException => @@ -71,6 +80,7 @@ export const isCursorSortableColumn = (column: PgColumn): boolean => const kindOf = (column: PgColumn): CursorKind => { if (!isArrayColumn(column) && temporalTypeOf(column)) return "temporal"; if (!isArrayColumn(column) && isUuidColumn(column)) return "uuid"; + if (!isArrayColumn(column) && isStringInt64Column(column)) return "bigint"; const kind = isArrayColumn(column) ? undefined @@ -226,6 +236,10 @@ export const cursorValueOf = ( case "bigint": { if (typeof value === "bigint") return value.toString(); if (typeof value === "number") return String(value); + // A string-mode bigint already is the decimal string the cursor carries. + if (typeof value === "string" && DECIMAL_INTEGER.test(value)) { + return value; + } break; } case "boolean": { @@ -298,7 +312,8 @@ export const cursorValueForColumn = ( const parsed = postgresBigint(value); if (parsed === undefined) throw badRequest(INVALID_CURSOR); - return parsed; + // Bound as the column reads it: a string-mode column maps strings. + return isStringInt64Column(column) ? parsed.toString() : parsed; } case "boolean": { if (typeof value !== "boolean") throw badRequest(INVALID_CURSOR); @@ -358,6 +373,7 @@ type IdentifierKind = "bigint" | "number" | "uuid"; const identifierKindOf = (column: PgColumn): IdentifierKind | undefined => { if (isArrayColumn(column)) return undefined; if (isUuidColumn(column)) return "uuid"; + if (isStringInt64Column(column)) return "bigint"; const baseDataType = baseDataTypeOf(column); if (baseDataType === "bigint" || baseDataType === "number") { @@ -377,6 +393,14 @@ export const cursorIdentifierOf = ( switch (identifierKindOf(column)) { case "bigint": if (typeof value === "bigint" && value > 0n) return value.toString(); + // A string-mode bigint: already canonical, never through `Number`. + if ( + typeof value === "string" && + POSITIVE_DECIMAL_INTEGER.test(value) && + postgresBigint(value) !== undefined + ) { + return value; + } break; case "number": if ( @@ -405,7 +429,9 @@ export const cursorIdentifierForColumn = ( case "bigint": if (typeof value === "string" && POSITIVE_DECIMAL_INTEGER.test(value)) { const parsed = postgresBigint(value); - if (parsed !== undefined) return parsed; + if (parsed !== undefined) { + return isStringInt64Column(column) ? parsed.toString() : parsed; + } } break; case "number": diff --git a/packages/vitnode/src/api/lib/with-pagination.ts b/packages/vitnode/src/api/lib/with-pagination.ts index 599eff9d18..bb56d8c117 100644 --- a/packages/vitnode/src/api/lib/with-pagination.ts +++ b/packages/vitnode/src/api/lib/with-pagination.ts @@ -210,13 +210,16 @@ async function fetchTotalCount( * A stable row identifier: an integer, bigint or UUID column. * * Drizzle refines these types (`serial` is `number int32`, bigint is - * `bigint int64`, UUID is `string uuid`), so the bound includes their refined + * `bigint int64` - or `string int64` in string mode, which is how a content + * type with `idStrategy: "bigint"` keys its rows - UUID is `string uuid`), so + * the bound includes their refined * forms. The first generic stays broad because built `PgColumn`s expose it that * way, as Drizzle's own `AnyPgColumn` does. */ type PaginationIdentifierDataType = | "bigint" | "number" + | "string int64" | "string uuid" | `bigint ${ColumnDataBigIntConstraint}` | `number ${ColumnDataNumberConstraint}`; diff --git a/packages/vitnode/src/api/models/search.ts b/packages/vitnode/src/api/models/search.ts index 82de823181..ceae1505d0 100644 --- a/packages/vitnode/src/api/models/search.ts +++ b/packages/vitnode/src/api/models/search.ts @@ -3,9 +3,11 @@ import type { Context } from "hono"; import { and, eq, inArray } from "drizzle-orm"; import { alias } from "drizzle-orm/pg-core"; +import type { ContentId } from "@/content/ids"; import type { RegisteredContentType } from "@/content/registry"; import { storageUrlOf } from "@/api/lib/storage-url"; +import { contentIdFromKey, contentIdKey } from "@/content/ids"; import { contentLocalesMatch } from "@/content/locale"; import { core_files } from "@/database/files"; import { core_search_index } from "@/database/search"; @@ -21,7 +23,12 @@ export interface SearchDocument { content: string; createdAt: Date; isPublic?: boolean; - itemId: number; + /** + * The item's own id: a number for every non-content indexer and every + * `serial` content type, the canonical string for a `uuid` or `bigint` one. + * Stored as its `contentIdKey`. + */ + itemId: ContentId; itemType: string; // Locale of this projection. Multi-language content emits one document per // language; single-language content may leave it empty. @@ -72,6 +79,33 @@ export const searchLanguageFallbacks = ( : [], ); +/** The shape of a key that reads back as the number it was written from. */ +const NUMERIC_KEY = /^[1-9][0-9]{0,14}$/; + +/** + * Turns a stored `itemId` key back into the id its item type uses. + * + * A registered content type reads it under its own id strategy, so a `bigint` + * stays a string however large and a `serial` is a number again. Any other item + * type - an indexer that predates content strategies - gets back the number it + * wrote when the key is plainly one, and the key itself otherwise. + */ +export const searchItemIdFromKey = ( + /** The installation's content types; absent where none are registered. */ + contentTypes: readonly RegisteredContentType[] | undefined, + itemType: string, + key: string, +): ContentId => { + const contentType = contentTypes?.find( + entry => entry.definition.id === itemType, + ); + if (contentType) { + return contentIdFromKey(contentType.definition.idStrategy, key) ?? key; + } + + return NUMERIC_KEY.test(key) ? Number(key) : key; +}; + export interface SearchHitAuthor { avatarColor: string; avatarUrl: null | string; @@ -89,7 +123,8 @@ export interface SearchHit { createdAt: Date; id: number; isPublic: boolean; - itemId: number; + /** As on {@link SearchDocument}: read back with {@link searchItemIdFromKey}. */ + itemId: ContentId; itemType: string; languageCode: string; metadata: Record; @@ -257,12 +292,15 @@ export interface SearchProviderApiPlugin { * that does not with a localized searchable content type refuses to boot. See * {@link assertSearchProviderCapabilities}. */ - delete: ( + // Method syntax on purpose: parameters stay bivariant, so a provider written + // when every id was a number still satisfies the interface. + // eslint-disable-next-line @typescript-eslint/method-signature-style + delete( c: Context, itemType: string, - itemId: number, + itemId: ContentId, languageCode?: string, - ) => Promise; + ): Promise; index: (c: Context, doc: SearchDocument) => Promise; name: string; ping?: (c: Context) => Promise; @@ -272,7 +310,7 @@ export interface SearchProviderApiPlugin { const toRow = (doc: SearchDocument) => ({ pluginId: doc.pluginId ?? "core", itemType: doc.itemType, - itemId: doc.itemId, + itemId: contentIdKey(doc.itemId), languageCode: doc.languageCode ?? "", authorIds: [...new Set(doc.authorIds ?? [])], title: doc.title, @@ -435,7 +473,7 @@ export class SearchModel { */ async delete( itemType: string, - itemId: number, + itemId: ContentId, languageCode?: string, ): Promise { await this.c @@ -444,7 +482,7 @@ export class SearchModel { .where( and( eq(core_search_index.itemType, itemType), - eq(core_search_index.itemId, itemId), + eq(core_search_index.itemId, contentIdKey(itemId)), languageCode === undefined ? undefined : eq(core_search_index.languageCode, languageCode), diff --git a/packages/vitnode/src/api/modules/content/helpers/execute-content-schedule.ts b/packages/vitnode/src/api/modules/content/helpers/execute-content-schedule.ts index 7d6524f144..0b4f13f8e9 100644 --- a/packages/vitnode/src/api/modules/content/helpers/execute-content-schedule.ts +++ b/packages/vitnode/src/api/modules/content/helpers/execute-content-schedule.ts @@ -1,12 +1,14 @@ import type { Context } from "hono"; +import type { ContentId } from "@/content/ids"; import type { ContentEditorialOutcome } from "@/content/server/editorial-service"; import type { ContentScheduleEffectsPayload } from "@/content/server/schedule-effects"; import type { AnyContentTypeDefinition } from "@/content/types"; import { CONTENT_QUEUE_TASK_SCHEDULE_EFFECTS } from "@/content/const"; +import { contentIdFromKey } from "@/content/ids"; import { CONTENT_SYSTEM_ACTOR } from "@/content/server/actor"; -import { findContentModel } from "@/content/server/model"; +import { contentDefinitionOf, findContentModel } from "@/content/server/model"; import { claimContentSchedule, settleContentSchedule, @@ -69,7 +71,8 @@ const effectsPayload = ({ action: "publish" | "unpublish"; createdBy: null | number; id: number; - itemId: number; + /** Decoded from the schedule row's key under the content type's strategy. */ + itemId: ContentId; }; definition: AnyContentTypeDefinition; outcome: ContentEditorialOutcome; @@ -164,9 +167,25 @@ export const executeContentSchedule = async ( } const { model, pluginId } = entry; + const definition = contentDefinitionOf(model); + + // The row stores a storage key; read it back under the content type's + // own strategy. A key that strategy cannot read names no record - a + // content type whose `idStrategy` changed under existing schedules - so + // it is cancelled like any other schedule that can never run. + const itemId = contentIdFromKey(definition.idStrategy, claimed.itemId); + if (itemId === null) { + await settleContentSchedule(tx, claimed.id, { + expectedStatus: "pending", + lastError: `Schedule ${claimed.id} names "${claimed.itemId}", which is not a ${definition.idStrategy} identifier of "${claimed.contentTypeId}".`, + status: "cancelled", + }); + + return { kind: "skipped", reason: "not an identifier of this type" }; + } const outcome = await editorialService(c, { pluginId })[claimed.action]( - claimed.itemId, + itemId, { // No fake user id anywhere. Who *asked* for this is on the schedule // row and travels in the event as `scheduledBy`. @@ -196,8 +215,8 @@ export const executeContentSchedule = async ( } const effects = effectsPayload({ - claimed, - definition: model.definition, + claimed: { ...claimed, itemId }, + definition, outcome, pluginId, }); diff --git a/packages/vitnode/src/api/modules/search/routes/search.route.ts b/packages/vitnode/src/api/modules/search/routes/search.route.ts index 2cb2627875..a9efd9e859 100644 --- a/packages/vitnode/src/api/modules/search/routes/search.route.ts +++ b/packages/vitnode/src/api/modules/search/routes/search.route.ts @@ -18,7 +18,8 @@ export const zodSearchHitSchema = z.object({ id: z.number(), pluginId: z.string(), itemType: z.string(), - itemId: z.number(), + // A number for every serial item, a string for a uuid or bigint content record. + itemId: z.union([z.number().int(), z.string().max(64)]), languageCode: z.string(), authorId: z.number().nullable(), title: z.string(), diff --git a/packages/vitnode/src/components/table/content.tsx b/packages/vitnode/src/components/table/content.tsx index 19b9bdaa99..35f7d84759 100644 --- a/packages/vitnode/src/components/table/content.tsx +++ b/packages/vitnode/src/components/table/content.tsx @@ -4,6 +4,7 @@ import React from "react"; import type { ColumnDef, DataTableProps, + DataTableRowId, DataTableTMin, } from "./data-table-content"; @@ -39,8 +40,8 @@ import { interface ExpansionColumnDataTable { canExpand: (row: T) => boolean; expandedIdOf: (row: T) => string; - isExpanded: (id: number) => boolean; - toggle: (id: number) => void; + isExpanded: (id: DataTableRowId) => boolean; + toggle: (id: DataTableRowId) => void; } const reorderColumn = (): ColumnDef => ({ @@ -134,7 +135,7 @@ const ReorderableBodyDataTable = ({ searchParams, }: Pick, "filters" | "order"> & { renderRow: (row: T) => React.ReactNode; - reorder: (activeId: number, overId: number) => void; + reorder: (activeId: DataTableRowId, overId: DataTableRowId) => void; reorderable: NonNullable["reorderable"]>; rows: T[]; searchParams: URLSearchParams; @@ -148,7 +149,7 @@ const ReorderableBodyDataTable = ({ }, filterIds: filters?.map(filter => filter.id) ?? [], }); - const rowLabelOf = (id: number) => { + const rowLabelOf = (id: DataTableRowId) => { const row = rows.find(item => item.id === id); return row ? reorderable.getRowLabel?.(row) : undefined; diff --git a/packages/vitnode/src/components/table/data-table-content.tsx b/packages/vitnode/src/components/table/data-table-content.tsx index c3455825f3..d9ca9c3a40 100644 --- a/packages/vitnode/src/components/table/data-table-content.tsx +++ b/packages/vitnode/src/components/table/data-table-content.tsx @@ -18,8 +18,14 @@ import { TableRow, } from "../ui/table"; +/** + * A row's identifier: a number for most tables, a string for a content type + * keyed by `uuid` or `bigint`. + */ +export type DataTableRowId = number | string; + export interface DataTableTMin { - id: number; + id: DataTableRowId; } export interface SearchParamsDataTable { diff --git a/packages/vitnode/src/components/table/expansion.tsx b/packages/vitnode/src/components/table/expansion.tsx index 13fac80ce2..5cfae78edb 100644 --- a/packages/vitnode/src/components/table/expansion.tsx +++ b/packages/vitnode/src/components/table/expansion.tsx @@ -5,13 +5,15 @@ import * as m from "motion/react-m"; import React from "react"; import { useTranslations } from "use-intl"; +import type { DataTableRowId } from "./data-table-content"; + import { MotionFeatures } from "../motion-features"; import { Button } from "../ui/button"; import { TableCell, TableRow } from "../ui/table"; export interface ExpandableDataTable { canExpand?: (row: T) => boolean; - defaultExpanded?: number[]; + defaultExpanded?: DataTableRowId[]; render: (row: T) => React.ReactNode; } @@ -19,10 +21,10 @@ export const useRowExpansionDataTable = ({ defaultExpanded, rowIds, }: { - defaultExpanded?: number[]; - rowIds: number[]; + defaultExpanded?: DataTableRowId[]; + rowIds: DataTableRowId[]; }) => { - const [expanded, setExpanded] = React.useState( + const [expanded, setExpanded] = React.useState( defaultExpanded ?? [], ); const pageKey = rowIds.join(","); @@ -39,11 +41,11 @@ export const useRowExpansionDataTable = ({ } const isExpanded = React.useCallback( - (id: number) => expanded.includes(id), + (id: DataTableRowId) => expanded.includes(id), [expanded], ); - const toggle = React.useCallback((id: number) => { + const toggle = React.useCallback((id: DataTableRowId) => { setExpanded(current => current.includes(id) ? current.filter(item => item !== id) diff --git a/packages/vitnode/src/components/table/reorder-state.ts b/packages/vitnode/src/components/table/reorder-state.ts index 877aaa7842..b6939edc25 100644 --- a/packages/vitnode/src/components/table/reorder-state.ts +++ b/packages/vitnode/src/components/table/reorder-state.ts @@ -1,22 +1,24 @@ import { arrayMove } from "@dnd-kit/sortable"; +import type { DataTableRowId } from "./data-table-content"; + import { readTableOrder, readTableSearch, type TableOrder } from "./url-state"; export interface ReorderDetailsDataTable { - activeId: number; + activeId: DataTableRowId; from: number; - overId: number; + overId: DataTableRowId; to: number; } export interface RowMoveDataTable extends ReorderDetailsDataTable { - ids: number[]; + ids: DataTableRowId[]; } export const moveRowId = ( - ids: readonly number[], - activeId: number, - overId: number, + ids: readonly DataTableRowId[], + activeId: DataTableRowId, + overId: DataTableRowId, ): null | RowMoveDataTable => { const from = ids.indexOf(activeId); const to = ids.indexOf(overId); diff --git a/packages/vitnode/src/components/table/reorder.tsx b/packages/vitnode/src/components/table/reorder.tsx index ea3794d5de..e1eab9f462 100644 --- a/packages/vitnode/src/components/table/reorder.tsx +++ b/packages/vitnode/src/components/table/reorder.tsx @@ -28,7 +28,7 @@ import React from "react"; import { toast } from "sonner"; import { useTranslations } from "use-intl"; -import type { DataTableTMin } from "./data-table-content"; +import type { DataTableRowId, DataTableTMin } from "./data-table-content"; import { Button } from "../ui/button"; import { moveRowId, type ReorderDetailsDataTable } from "./reorder-state"; @@ -37,13 +37,13 @@ export interface ReorderableDataTable { disabled?: boolean; getRowLabel?: (row: T) => string; onReorder: ( - ids: number[], + ids: DataTableRowId[], details: ReorderDetailsDataTable, ) => Promise | void; } interface RowOrderOverride { - ids: number[]; + ids: DataTableRowId[]; key: string; } @@ -67,7 +67,7 @@ export const useRowOrderDataTable = ({ return row ? [row] : []; }); - const reorder = (activeId: number, overId: number) => { + const reorder = (activeId: DataTableRowId, overId: DataTableRowId) => { const move = moveRowId(ids, activeId, overId); if (!move || !onReorder) return; @@ -123,9 +123,9 @@ export function ReorderProviderDataTable({ onMove, }: { children: React.ReactNode; - ids: number[]; - labelOf: (id: number) => string | undefined; - onMove: (activeId: number, overId: number) => void; + ids: DataTableRowId[]; + labelOf: (id: DataTableRowId) => string | undefined; + onMove: (activeId: DataTableRowId, overId: DataTableRowId) => void; }) { const t = useTranslations("core.global.data_table"); const contextId = React.useId(); @@ -144,7 +144,7 @@ export function ReorderProviderDataTable({ }), ); - const spoken = (activeId: number, overId: number) => ({ + const spoken = (activeId: DataTableRowId, overId: DataTableRowId) => ({ name: labelOf(activeId) ?? t("row_fallback", { position: ids.indexOf(activeId) + 1 }), @@ -154,17 +154,13 @@ export function ReorderProviderDataTable({ const announcements: Announcements = { onDragCancel: ({ active }) => - t("reorder_cancelled", spoken(Number(active.id), Number(active.id))), + t("reorder_cancelled", spoken(active.id, active.id)), onDragEnd: ({ active, over }) => - over - ? t("reorder_ended", spoken(Number(active.id), Number(over.id))) - : undefined, + over ? t("reorder_ended", spoken(active.id, over.id)) : undefined, onDragOver: ({ active, over }) => - over - ? t("reorder_over", spoken(Number(active.id), Number(over.id))) - : undefined, + over ? t("reorder_over", spoken(active.id, over.id)) : undefined, onDragStart: ({ active }) => - t("reorder_started", spoken(Number(active.id), Number(active.id))), + t("reorder_started", spoken(active.id, active.id)), }; return ( @@ -178,7 +174,8 @@ export function ReorderProviderDataTable({ id={contextId} modifiers={[restrictToVerticalAxis, restrictToParentElement]} onDragEnd={({ active, over }) => { - if (over) onMove(Number(active.id), Number(over.id)); + // dnd-kit hands back the very ids `SortableContext` was given. + if (over) onMove(active.id, over.id); }} sensors={sensors} > @@ -198,7 +195,7 @@ export function SortableRowGroupDataTable({ }: { children: React.ReactNode; disabled: boolean; - id: number; + id: DataTableRowId; label?: string; position: number; }) { diff --git a/packages/vitnode/src/components/table/selection.tsx b/packages/vitnode/src/components/table/selection.tsx index d8063d1834..8099079e43 100644 --- a/packages/vitnode/src/components/table/selection.tsx +++ b/packages/vitnode/src/components/table/selection.tsx @@ -5,6 +5,8 @@ import React from "react"; import { createPortal } from "react-dom"; import { useTranslations } from "use-intl"; +import type { DataTableRowId } from "./data-table-content"; + import { MotionFeatures } from "../motion-features"; import { Button } from "../ui/button"; import { Checkbox } from "../ui/checkbox"; @@ -16,10 +18,10 @@ import { TooltipWithContent } from "../ui/tooltip"; export interface SelectionDataTable { allSelected: boolean; clear: () => void; - isSelected: (id: number) => boolean; - selected: number[]; + isSelected: (id: DataTableRowId) => boolean; + selected: DataTableRowId[]; someSelected: boolean; - toggle: (id: number) => void; + toggle: (id: DataTableRowId) => void; toggleAll: (next: boolean) => void; } @@ -48,9 +50,9 @@ export function SelectionProviderDataTable({ rowIds, }: { children: React.ReactNode; - rowIds: number[]; + rowIds: DataTableRowId[]; }) { - const [selected, setSelected] = React.useState([]); + const [selected, setSelected] = React.useState([]); const pageKey = rowIds.join(","); const [prevPageKey, setPrevPageKey] = React.useState(pageKey); @@ -104,7 +106,7 @@ export function SelectAllDataTable() { ); } -export function SelectRowDataTable({ id }: { id: number }) { +export function SelectRowDataTable({ id }: { id: DataTableRowId }) { const t = useTranslations("core.global.data_table"); const { isSelected, toggle } = useDataTableSelection(); @@ -122,7 +124,7 @@ export function RowSelectableDataTable({ id, }: { children: React.ReactNode; - id: number; + id: DataTableRowId; }) { const { isSelected } = useDataTableSelection(); diff --git a/packages/vitnode/src/content/admin/route.ts b/packages/vitnode/src/content/admin/route.ts index 0b01e22eb6..62834aa225 100644 --- a/packages/vitnode/src/content/admin/route.ts +++ b/packages/vitnode/src/content/admin/route.ts @@ -1,9 +1,11 @@ +import type { ContentId, ContentIdStrategy } from "../ids"; import type { AnyContentTypeDefinition } from "../types"; import { CONTENT_ADMIN_CREATE_SEGMENT, CONTENT_ADMIN_EDIT_SEGMENT, } from "../const"; +import { parseContentId } from "../ids"; /** What `/admin/content/[...slug]` was actually asked for. */ export type ContentAdminAction = "create" | "edit" | "list"; @@ -12,17 +14,31 @@ export interface ContentAdminRoute { action: ContentAdminAction; /** The content type id the slug resolved to. */ contentTypeId: string; - /** The record being edited. Only ever set for `edit`. */ - itemId?: number; + /** + * The record being edited. Only ever set for `edit`: a number for a `serial` + * content type, the canonical string for a `uuid` or `bigint` one. + */ + itemId?: ContentId; } export type ContentTypeLookup = ( adminPath: string, ) => AnyContentTypeDefinition | undefined; -/** Only a positive integer is a record id - `01`, `1.5` and `-1` are not. */ -const parseItemId = (segment: string | undefined): null | number => { - if (segment === undefined || !/^[1-9][0-9]*$/.test(segment)) return null; +/** + * The record id in an edit URL, under the content type's own strategy. + * + * A `serial` id is a positive integer - `01`, `1.5` and `-1` are not. A `uuid` + * is its canonical lowercase spelling and a `bigint` its decimal digits, so one + * record has exactly one edit URL. + */ +const parseItemId = ( + strategy: ContentIdStrategy, + segment: string | undefined, +): ContentId | null => { + if (segment === undefined) return null; + if (strategy !== "serial") return parseContentId(strategy, segment); + if (!/^[1-9][0-9]*$/.test(segment)) return null; const id = Number(segment); @@ -48,12 +64,13 @@ export const resolveContentAdminRoute = ( } if (last === CONTENT_ADMIN_EDIT_SEGMENT) { - const itemId = parseItemId(slug[slug.length - 2]); - if (itemId === null) return undefined; - + // The definition first: which spelling is an id depends on its strategy. const definition = lookup(slug.slice(0, -2).join("/")); if (definition?.admin.edit.mode !== "page") return undefined; + const itemId = parseItemId(definition.idStrategy, slug[slug.length - 2]); + if (itemId === null) return undefined; + return { action: "edit", contentTypeId: definition.id, itemId }; } diff --git a/packages/vitnode/src/content/admin/spec.ts b/packages/vitnode/src/content/admin/spec.ts index d75a62fb06..ee9cee64bb 100644 --- a/packages/vitnode/src/content/admin/spec.ts +++ b/packages/vitnode/src/content/admin/spec.ts @@ -1,5 +1,6 @@ import { z } from "zod"; +import type { ContentId, ContentIdStrategy } from "../ids"; import type { AnyContentTypeDefinition, ContentFieldAiAssist, @@ -20,6 +21,7 @@ import { contentRepeatableMax, contentRepeatableMin, } from "../advanced"; +import { contentIdFromKey, contentIdKey, contentIdSchema } from "../ids"; import { contentFieldPath, contentInnerFields, @@ -71,6 +73,12 @@ export interface ContentFormFieldSpec { required: boolean; targetContentTypeId?: string; + /** + * A relation target's id strategy, so the form keeps a `uuid` or `bigint` + * target's ids as strings and a `serial` one's as numbers. Absent for a + * `user` field, whose ids are always numbers. + */ + targetIdStrategy?: ContentIdStrategy; } export interface ContentFormSectionSpec { @@ -234,8 +242,13 @@ export const projectFormField = ( // client component, and a function cannot. Calling it is what // `resolveReferenceTargets` already does per request, so a circular // reference is as safe here as it is there. + // A self-relation's thunk is bound to its own definition, so the same + // call answers both. ...(fieldValue.kind === "relation" - ? { targetContentTypeId: fieldValue.target().id } + ? { + targetContentTypeId: fieldValue.target().id, + targetIdStrategy: fieldValue.target().idStrategy, + } : {}), }; case "slug": @@ -424,13 +437,35 @@ const richTextFormSchema = (spec: ContentFormFieldSpec): z.ZodType => { ) : doc; }; +/** Whether a reference field's ids are strings: a `uuid` or `bigint` target. */ +const hasStringIds = (spec: ContentFormFieldSpec): boolean => + spec.kind === "relation" && + spec.targetIdStrategy !== undefined && + spec.targetIdStrategy !== "serial"; const referenceSetSchema = (spec: ContentFormFieldSpec): z.ZodType => { - const schema = z.array(z.number()); + const schema = z.array( + hasStringIds(spec) && spec.targetIdStrategy !== undefined + ? contentIdSchema(spec.targetIdStrategy) + : z.number(), + ); return spec.minItems === undefined ? schema : schema.min(spec.minItems); }; +/** + * A picker option's `value` - always a string, because a combobox keys by + * one - back to the identifier the API takes, under the target's strategy. + * `null` for a value that strategy cannot read. + */ +export const contentOptionValueToId = ( + spec: ContentFormFieldSpec, + value: string, +): ContentId | null => + hasStringIds(spec) && spec.targetIdStrategy !== undefined + ? contentIdFromKey(spec.targetIdStrategy, value) + : Number(value); + const baseFieldSchema = (spec: ContentFormFieldSpec): z.ZodType => { switch (spec.kind) { case "blocks": @@ -532,7 +567,10 @@ const toInitialValue = ( if (fieldSpec.multiple === true) return current; if (current === null || current === undefined) return undefined; - const id = typeof current === "number" ? current.toString() : ""; + const id = + typeof current === "number" || typeof current === "string" + ? contentIdKey(current) + : ""; return { label: labels[fieldSpec.name] ?? id, value: id }; }; @@ -579,7 +617,7 @@ export const contentFormValuesToPayload = ( const option = value as ContentReferenceOption | null | undefined; if (!option?.value) return [name, null]; - return [name, Number(option.value)]; + return [name, contentOptionValueToId(fieldSpec, option.value)]; }), ); diff --git a/packages/vitnode/src/content/cache.ts b/packages/vitnode/src/content/cache.ts index 10443cd49d..96990d0686 100644 --- a/packages/vitnode/src/content/cache.ts +++ b/packages/vitnode/src/content/cache.ts @@ -1,7 +1,9 @@ +import type { ContentId } from "./ids"; import type { ContentLocalizationFallback } from "./types"; import { CONTENT_CACHE_TAG_MAX_LENGTH } from "./const"; import { clampWithFingerprint } from "./hash"; +import { contentIdKey } from "./ids"; import { contentLocalesMatch, normalizeContentLocale } from "./locale"; const tag = (...parts: (number | string)[]): string => @@ -26,9 +28,10 @@ export const contentPublicListTag = ( /** One row, by identifier, in one locale. */ export const contentPublicItemTag = ( contentTypeId: string, - id: number, + id: ContentId, locale?: string, -): string => tag(contentTypeId, "item", ...localeParts(locale), id); +): string => + tag(contentTypeId, "item", ...localeParts(locale), contentIdKey(id)); export const contentPublicSlugTag = ( contentTypeId: string, @@ -38,9 +41,10 @@ export const contentPublicSlugTag = ( export const contentDeliveryTag = ( contentTypeId: string, - id: number, + id: ContentId, locale?: string, -): string => tag(contentTypeId, "delivery", ...localeParts(locale), id); +): string => + tag(contentTypeId, "delivery", ...localeParts(locale), contentIdKey(id)); export const contentDeliveryRedirectTag = ( contentTypeId: string, @@ -76,7 +80,7 @@ export interface ContentSitemapChange { export interface ContentInvalidationInput { contentTypeId: string; delivery?: ContentDeliveryInvalidation; - id: number; + id: ContentId; /** Whether the row is publicly reachable *after* the mutation. */ isPublic: boolean; @@ -149,7 +153,7 @@ const deliveryTags = ({ }: { contentTypeId: string; delivery: ContentDeliveryInvalidation | undefined; - id: number; + id: ContentId; locales: readonly { isPublic: boolean; locale: string | undefined; diff --git a/packages/vitnode/src/content/conflicts.ts b/packages/vitnode/src/content/conflicts.ts index f99c75bea4..bca7fcf8f3 100644 --- a/packages/vitnode/src/content/conflicts.ts +++ b/packages/vitnode/src/content/conflicts.ts @@ -21,18 +21,27 @@ export type ContentTranslationConflictCode = export type ContentUnprocessableCode = (typeof CONTENT_UNPROCESSABLE_CODES)[keyof typeof CONTENT_UNPROCESSABLE_CODES]; +/** + * A record identifier in an error body: a number for a `serial` record, the + * canonical string for a `uuid` or `bigint` one. + */ +export const zodContentItemId = z.union([ + z.number().int(), + z.string().min(1).max(64), +]); + export const zodContentConflict = z.discriminatedUnion("code", [ z.object({ code: z.literal(CONTENT_CONFLICT_CODES.version), contentTypeId: z.string(), currentVersion: z.number().int(), expectedVersion: z.number().int(), - itemId: z.number().int(), + itemId: zodContentItemId, }), z.object({ code: z.literal(CONTENT_CONFLICT_CODES.unique), contentTypeId: z.string(), - itemId: z.number().int().nullable(), + itemId: zodContentItemId.nullable(), }), ]); @@ -44,19 +53,19 @@ export const zodContentTranslationConflict = z.discriminatedUnion("code", [ contentTypeId: z.string(), currentVersion: z.number().int(), expectedVersion: z.number().int(), - itemId: z.number().int(), + itemId: zodContentItemId, locale: z.string(), }), z.object({ code: z.literal(CONTENT_TRANSLATION_CONFLICT_CODES.defaultRequired), contentTypeId: z.string(), - itemId: z.number().int(), + itemId: zodContentItemId, locale: z.string(), }), z.object({ code: z.literal(CONTENT_TRANSLATION_CONFLICT_CODES.exists), contentTypeId: z.string(), - itemId: z.number().int(), + itemId: zodContentItemId, locale: z.string(), }), z.object({ @@ -67,7 +76,7 @@ export const zodContentTranslationConflict = z.discriminatedUnion("code", [ z.object({ code: z.literal(CONTENT_TRANSLATION_CONFLICT_CODES.unique), contentTypeId: z.string(), - itemId: z.number().int().nullable(), + itemId: zodContentItemId.nullable(), locale: z.string(), }), ]); diff --git a/packages/vitnode/src/content/const.ts b/packages/vitnode/src/content/const.ts index 0942591dad..cfb4972743 100644 --- a/packages/vitnode/src/content/const.ts +++ b/packages/vitnode/src/content/const.ts @@ -24,6 +24,14 @@ export const CONTENT_ID_STRATEGIES = ["serial", "uuid", "bigint"] as const; export const CONTENT_DEFAULT_ID_STRATEGY = "serial"; +/** + * `varchar` length of an `itemId` in the tables shared by every content type - + * revisions, schedules, slug history and the search index. They hold + * `contentIdKey(id)`: the digits of a `serial` or `bigint` (at most 19) or a + * canonical UUID (36), with room to spare. + */ +export const CONTENT_ID_KEY_LENGTH = 64; + export const CONTENT_TRANSLATION_SYSTEM_FIELDS = [ "itemId", "languageId", diff --git a/packages/vitnode/src/content/define.ts b/packages/vitnode/src/content/define.ts index 2f505866a0..da3328192d 100644 --- a/packages/vitnode/src/content/define.ts +++ b/packages/vitnode/src/content/define.ts @@ -598,6 +598,7 @@ export const defineContentType = < advanced: resolvedAdvanced, editorial: editorialEnabled, fields: fieldMap, + idStrategy: resolvedIdStrategy, localization: resolvedLocalization, publicApi: resolvedPublicApi, publication: publicationEnabled, diff --git a/packages/vitnode/src/content/errors.ts b/packages/vitnode/src/content/errors.ts index d63a997db9..d596a47434 100644 --- a/packages/vitnode/src/content/errors.ts +++ b/packages/vitnode/src/content/errors.ts @@ -1,4 +1,5 @@ import type { CONTENT_ADVANCED_CODES } from "./const"; +import type { ContentId } from "./ids"; import type { ContentScheduleCode } from "./schedules"; export type ContentAdvancedCode = @@ -62,7 +63,7 @@ export class ContentVersionConflict extends ContentEngineError { contentTypeId: string; currentVersion: number; expectedVersion: number; - itemId: number; + itemId: ContentId; }) { super( `This record is at version ${currentVersion}, not ${expectedVersion}. Someone else saved it first.`, @@ -77,7 +78,7 @@ export class ContentVersionConflict extends ContentEngineError { readonly currentVersion: number; readonly expectedVersion: number; - readonly itemId: number; + readonly itemId: ContentId; } /** @@ -130,7 +131,7 @@ export class ContentTranslationVersionConflict extends ContentEngineError { contentTypeId: string; currentVersion: number; expectedVersion: number; - itemId: number; + itemId: ContentId; locale: string; }) { super( @@ -147,7 +148,7 @@ export class ContentTranslationVersionConflict extends ContentEngineError { readonly currentVersion: number; readonly expectedVersion: number; - readonly itemId: number; + readonly itemId: ContentId; readonly locale: string; } @@ -166,7 +167,7 @@ export class ContentDefaultTranslationRequired extends ContentEngineError { locale, }: { contentTypeId: string; - itemId: number; + itemId: ContentId; locale: string; }) { super( @@ -179,7 +180,7 @@ export class ContentDefaultTranslationRequired extends ContentEngineError { this.locale = locale; } - readonly itemId: number; + readonly itemId: ContentId; readonly locale: string; } @@ -190,7 +191,7 @@ export class ContentTranslationExists extends ContentEngineError { locale, }: { contentTypeId: string; - itemId: number; + itemId: ContentId; locale: string; }) { super( @@ -203,7 +204,7 @@ export class ContentTranslationExists extends ContentEngineError { this.locale = locale; } - readonly itemId: number; + readonly itemId: ContentId; readonly locale: string; } @@ -239,7 +240,7 @@ export class ContentTranslationItemMissing extends ContentEngineError { itemId, }: { contentTypeId: string; - itemId: number; + itemId: ContentId; }) { super(`No record with id ${itemId} to translate.`, { contentTypeId }); @@ -247,7 +248,7 @@ export class ContentTranslationItemMissing extends ContentEngineError { this.itemId = itemId; } - readonly itemId: number; + readonly itemId: ContentId; } /** @@ -274,7 +275,7 @@ export class ContentAdvancedInputError extends ContentInputError { code: ContentAdvancedCode; contentTypeId: string; field: string; - ids: number[]; + ids: ContentId[]; message: string; }) { super(message, { contentTypeId }); @@ -287,7 +288,7 @@ export class ContentAdvancedInputError extends ContentInputError { readonly code: ContentAdvancedCode; readonly field: string; - readonly ids: number[]; + readonly ids: ContentId[]; } /** diff --git a/packages/vitnode/src/content/events.ts b/packages/vitnode/src/content/events.ts index b9eeb33c91..5f20a60a19 100644 --- a/packages/vitnode/src/content/events.ts +++ b/packages/vitnode/src/content/events.ts @@ -22,8 +22,8 @@ export type ContentEventAction = | "unpublished" | "updated"; -export interface ContentCreatedPayload { - contentId: number; +export interface ContentCreatedPayload { + contentId: TId; } /** @@ -66,17 +66,17 @@ export interface ContentUnhiddenPayload { contentId: TId; } -export interface ContentDeletedPayload { - contentId: number; +export interface ContentDeletedPayload { + contentId: TId; } export interface ContentUpdatedPayload { changedFields: ContentFieldName[]; - contentId: number; + contentId: ContentIdOf; } -export interface ContentPublishedPayload { - contentId: number; +export interface ContentPublishedPayload { + contentId: TId; /** When the row was published for the *first* time; never rewritten. */ publishedAt: Date; @@ -85,8 +85,8 @@ export interface ContentPublishedPayload { scheduleId?: number; } -export interface ContentUnpublishedPayload { - contentId: number; +export interface ContentUnpublishedPayload { + contentId: TId; /** As on `published`: who scheduled it, when a schedule fired it. */ scheduledBy?: null | number; /** As on `published`: the booking, and the idempotency key for retries. */ @@ -95,7 +95,7 @@ export interface ContentUnpublishedPayload { export interface ContentRestoredPayload { changedFields: ContentFieldName[]; - contentId: number; + contentId: ContentIdOf; /** The revision the values came from. */ restoredFromRevisionId: number; /** The revision this restore itself created. */ @@ -103,19 +103,19 @@ export interface ContentRestoredPayload { version: number; } -export interface ContentScheduledPayload { +export interface ContentScheduledPayload { action: "publish" | "unpublish"; /** The staff member who booked it. */ actorUserId: null | number; - contentId: number; + contentId: TId; scheduledFor: Date; scheduleId: number; } -export interface ContentScheduleCancelledPayload { +export interface ContentScheduleCancelledPayload { action: "publish" | "unpublish"; actorUserId: null | number; - contentId: number; + contentId: TId; scheduleId: number; } @@ -123,11 +123,11 @@ type ContentPublicationEventsFor = TDefinition extends { publication: { enabled: true } } ? Record< `content.${TDefinition["id"]}.published`, - ContentPublishedPayload + ContentPublishedPayload> > & Record< `content.${TDefinition["id"]}.unpublished`, - ContentUnpublishedPayload + ContentUnpublishedPayload> > : Record; @@ -147,11 +147,11 @@ type ContentEditorialEventsFor = (TDefinition extends { editorial: { scheduling: { enabled: true } } } ? Record< `content.${TDefinition["id"]}.schedule_cancelled`, - ContentScheduleCancelledPayload + ContentScheduleCancelledPayload> > & Record< `content.${TDefinition["id"]}.scheduled`, - ContentScheduledPayload + ContentScheduledPayload> > : Record); @@ -169,8 +169,8 @@ type ContentEditorialEventsFor = * inspect `changedFields` to tell them apart would get it wrong the first time a * field was renamed. */ -export interface ContentTranslationEventPayload { - contentId: number; +export interface ContentTranslationEventPayload { + contentId: TId; languageId: number; /** The canonical `core_languages.code`. */ locale: string; @@ -178,7 +178,9 @@ export interface ContentTranslationEventPayload { version: number; } -export interface ContentTranslationCreatedPayload extends ContentTranslationEventPayload { +export interface ContentTranslationCreatedPayload< + TId = number, +> extends ContentTranslationEventPayload { /** * The revision this mutation wrote. * @@ -191,29 +193,35 @@ export interface ContentTranslationCreatedPayload extends ContentTranslationEven export interface ContentTranslationUpdatedPayload< TDefinition, -> extends ContentTranslationEventPayload { +> extends ContentTranslationEventPayload> { /** Localized field names this write moved. Never a shared field. */ changedFields: ContentLocalizedFieldName[]; revisionId?: number; } -export interface ContentTranslationDeletedPayload extends ContentTranslationEventPayload { +export interface ContentTranslationDeletedPayload< + TId = number, +> extends ContentTranslationEventPayload { revisionId?: number; } -export interface ContentTranslationPublishedPayload extends ContentTranslationEventPayload { +export interface ContentTranslationPublishedPayload< + TId = number, +> extends ContentTranslationEventPayload { /** When this language was first published; never rewritten. */ publishedAt: Date | null; revisionId?: number; } -export interface ContentTranslationUnpublishedPayload extends ContentTranslationEventPayload { +export interface ContentTranslationUnpublishedPayload< + TId = number, +> extends ContentTranslationEventPayload { revisionId?: number; } export interface ContentTranslationRestoredPayload< TDefinition, -> extends ContentTranslationEventPayload { +> extends ContentTranslationEventPayload> { changedFields: ContentLocalizedFieldName[]; /** The revision the values came from - always one of this locale's own. */ restoredFromRevisionId: number; @@ -248,21 +256,21 @@ type ContentLocalizationEventsFor = } ? Record< `content.${TDefinition["id"]}.translation_published`, - ContentTranslationPublishedPayload + ContentTranslationPublishedPayload> > & Record< `content.${TDefinition["id"]}.translation_unpublished`, - ContentTranslationUnpublishedPayload + ContentTranslationUnpublishedPayload> > : Record) & (TDefinition extends { localization: { enabled: true } } ? Record< `content.${TDefinition["id"]}.translation_created`, - ContentTranslationCreatedPayload + ContentTranslationCreatedPayload> > & Record< `content.${TDefinition["id"]}.translation_deleted`, - ContentTranslationDeletedPayload + ContentTranslationDeletedPayload> > & Record< `content.${TDefinition["id"]}.translation_updated`, @@ -283,10 +291,10 @@ type ContentLocalizationEventsFor = * `locale` is `null` when the slug is shared - a content type that is not * localized, or a localized one whose slug lives on the base row. */ -export interface ContentDeliverySlugChangedPayload { +export interface ContentDeliverySlugChangedPayload { /** The path the record answers to now. */ canonicalPath: string; - contentId: number; + contentId: TId; locale: null | string; /** The path it answered to before, or `null` when it had no public URL yet. */ previousPath: null | string; @@ -304,10 +312,10 @@ export interface ContentDeliverySlugChangedPayload { * field", and it is why this is a separate event from the one above rather than a * boolean on it. */ -export interface ContentDeliveryRedirectCreatedPayload { +export interface ContentDeliveryRedirectCreatedPayload { /** Where the historical path now redirects to. */ canonicalPath: string; - contentId: number; + contentId: TId; locale: null | string; /** The retired path, which now answers with a permanent redirect. */ previousPath: string; @@ -332,11 +340,11 @@ type ContentDeliveryEventsFor = TDefinition extends { delivery: { enabled: true } } ? Record< `content.${TDefinition["id"]}.delivery_redirect_created`, - ContentDeliveryRedirectCreatedPayload + ContentDeliveryRedirectCreatedPayload> > & Record< `content.${TDefinition["id"]}.delivery_slug_changed`, - ContentDeliverySlugChangedPayload + ContentDeliverySlugChangedPayload> > : Record; @@ -391,9 +399,12 @@ export type ContentEventsFor = `content.${TDefinition["id"]}.created`, TDefinition extends { duplication: { enabled: true } } ? ContentDuplicableCreatedPayload> - : ContentCreatedPayload + : ContentCreatedPayload> + > & + Record< + `content.${TDefinition["id"]}.deleted`, + ContentDeletedPayload> > & - Record<`content.${TDefinition["id"]}.deleted`, ContentDeletedPayload> & Record< `content.${TDefinition["id"]}.updated`, ContentUpdatedPayload diff --git a/packages/vitnode/src/content/fields.ts b/packages/vitnode/src/content/fields.ts index bc4e4d18b5..189d749d73 100644 --- a/packages/vitnode/src/content/fields.ts +++ b/packages/vitnode/src/content/fields.ts @@ -11,6 +11,7 @@ import type { ContentNumberField, ContentOnDelete, ContentRelationField, + ContentRelationTarget, ContentRepeatableField, ContentRichTextField, ContentSlugField, @@ -293,6 +294,11 @@ const relation = < TMultiple extends boolean = false, TOrdered extends boolean = false, TSelf extends boolean = false, + // Inferred from the thunk, so a relation's values are typed by its target's id + // strategy. Defaults to `serial` - what every target written before + // strategies existed is - and a `self` relation keeps that placeholder until + // `ContentFieldsOf` rebinds it to the owner's strategy. + TTarget extends ContentRelationTarget = ContentRelationTarget<"serial">, >( args: SharedArgs & { min?: number; @@ -301,9 +307,16 @@ const relation = < ordered?: TOrdered; /** The target is this content type. Mutually exclusive with `target`. */ self?: TSelf; - target?: () => AnyContentTypeDefinition; + target?: () => TTarget; }, -): ContentRelationField => ({ +): ContentRelationField< + TRequired, + TNullable, + TMultiple, + TOrdered, + TSelf, + TSelf extends true ? "serial" : TTarget["idStrategy"] +> => ({ ...args, ...shared(args), kind: "relation", @@ -314,7 +327,11 @@ const relation = < onDelete: args.onDelete ?? "restrict", ordered: (args.ordered ?? false) as TOrdered, self: (args.self ?? false) as TSelf, - target: args.target ?? unboundSelfTarget, + // The descriptor's own strategy is the type-level contract; at runtime the + // thunk returns whichever definition it names. + target: (args.target ?? unboundSelfTarget) as () => ContentRelationTarget< + TSelf extends true ? "serial" : TTarget["idStrategy"] + >, }); const group = < diff --git a/packages/vitnode/src/content/ids.ts b/packages/vitnode/src/content/ids.ts index d73af1a2cb..21345a3c86 100644 --- a/packages/vitnode/src/content/ids.ts +++ b/packages/vitnode/src/content/ids.ts @@ -2,6 +2,8 @@ import { z } from "zod"; import type { CONTENT_ID_STRATEGIES } from "./const"; +import { ContentEngineError } from "./errors"; + /** How a content type's primary key is generated. Defaults to `serial`. */ export type ContentIdStrategy = (typeof CONTENT_ID_STRATEGIES)[number]; @@ -194,3 +196,75 @@ export const compareContentIds = ( return left < right ? -1 : left > right ? 1 : 0; }; + +/** + * {@link parseContentId} for a value the engine itself produced - a row it just + * read, a key it wrote - where a miss is a bug rather than bad input. Throws + * instead of returning a sentinel, so a uuid or bigint record is never skipped + * because somebody once assumed every id was a number. + */ +export const requireContentId = ( + strategy: ContentIdStrategy, + value: unknown, + contentTypeId?: string, +): ContentId => { + const parsed = parseContentId(strategy, value); + if (parsed === null) { + throw new ContentEngineError( + `Expected a ${strategy} record identifier, got ${JSON.stringify(typeof value === "bigint" ? value.toString() : value)}.`, + { contentTypeId }, + ); + } + + return parsed; +}; + +/** + * Every value of a list that is a valid identifier under `strategy`, in its + * canonical representation. Anything else is dropped - for lists read back from + * the engine's own storage, where an unparseable entry cannot name a record. + */ +export const contentIdsOf = ( + strategy: ContentIdStrategy, + values: readonly unknown[], +): ContentId[] => { + const ids: ContentId[] = []; + for (const value of values) { + const parsed = parseContentId(strategy, value); + if (parsed !== null) ids.push(parsed); + } + + return ids; +}; + +/** The Postgres type of a primary key, and of every foreign key that points at one. */ +export const contentIdSqlType = ( + strategy: ContentIdStrategy, +): "bigint" | "integer" | "uuid" => + strategy === "serial" ? "integer" : strategy; + +/** + * The id strategy a relation field's values follow: the owner's for a + * self-relation, the target's otherwise. Reads the target thunk, so call it at + * request time, once every content type module has loaded - never while a + * table is being built (see `contentRelationBuildStrategy`). + */ +export const contentRelationStrategy = ( + owner: { id: string; idStrategy: ContentIdStrategy }, + fieldValue: { + self: boolean; + target: () => undefined | { idStrategy: ContentIdStrategy }; + }, +): ContentIdStrategy => { + if (fieldValue.self) return owner.idStrategy; + + const target = fieldValue.target(); + if (!target) { + throw new ContentEngineError( + "A relation's target resolved to nothing. Usually a circular import between content type modules that read a target while it was still loading.", + { contentTypeId: owner.id }, + ); + } + + return target.idStrategy; +}; diff --git a/packages/vitnode/src/content/index.ts b/packages/vitnode/src/content/index.ts index d3605ce0c8..ee6dede4c4 100644 --- a/packages/vitnode/src/content/index.ts +++ b/packages/vitnode/src/content/index.ts @@ -275,9 +275,13 @@ export { contentIdKey, contentIdParamSchema, contentIdSchema, + contentIdsOf, + contentIdSqlType, + contentRelationStrategy, isContentId, parseContentId, parseContentIdOf, + requireContentId, } from "./ids"; export type { ContentId, @@ -406,6 +410,7 @@ export type { ContentAdminFormMode, ContentAdminFormSection, ContentAdminListConfig, + ContentBindSelfRelations, ContentBlocksField, ContentBooleanField, ContentCreateInput, @@ -461,7 +466,11 @@ export type { ContentPublicSelect, ContentReferenceField, ContentReferenceFieldName, + ContentReferenceIdOf, ContentRelationField, + ContentRelationFilter, + ContentRelationIdOf, + ContentRelationTarget, ContentRichTextField, ContentSchedulingEnabled, ContentSearchAuthorField, diff --git a/packages/vitnode/src/content/registry.ts b/packages/vitnode/src/content/registry.ts index 002cffd64f..7f153c1a31 100644 --- a/packages/vitnode/src/content/registry.ts +++ b/packages/vitnode/src/content/registry.ts @@ -3,6 +3,7 @@ import type { PermissionStaffEntryInput, PermissionStaffModulesInput, } from "../api/lib/permission-staff"; +import type { ContentId } from "./ids"; import type { AnyContentTypeDefinition } from "./types"; import { @@ -16,6 +17,7 @@ import { RESERVED_FILTER_KEYS, } from "./const"; import { ContentEngineError } from "./errors"; +import { contentIdKey } from "./ids"; import { partitionContentFields } from "./localization"; /** A definition plus the plugin that registered it. */ @@ -342,9 +344,9 @@ export const contentCreateHref = ( /** `/admin/content/blog/articles/42/edit` - the generated edit **page**. */ export const contentEditHref = ( definition: ContentAdminAddressable, - itemId: number, + itemId: ContentId, ): string => - `${contentAdminHref(definition)}/${itemId}/${CONTENT_ADMIN_EDIT_SEGMENT}`; + `${contentAdminHref(definition)}/${contentIdKey(itemId)}/${CONTENT_ADMIN_EDIT_SEGMENT}`; /** * The edit URL with `{id}` still in it. @@ -355,11 +357,7 @@ export const contentEditHref = ( */ export const contentEditHrefTemplate = ( definition: ContentAdminAddressable, -): string => - contentEditHref( - definition, - CONTENT_EDIT_HREF_PLACEHOLDER as unknown as number, - ); +): string => contentEditHref(definition, CONTENT_EDIT_HREF_PLACEHOLDER); /** The token {@link contentEditHrefTemplate} leaves behind for the client. */ export const CONTENT_EDIT_HREF_PLACEHOLDER = "{id}"; diff --git a/packages/vitnode/src/content/revisions.ts b/packages/vitnode/src/content/revisions.ts index a7874780bb..301d0bab04 100644 --- a/packages/vitnode/src/content/revisions.ts +++ b/packages/vitnode/src/content/revisions.ts @@ -4,6 +4,7 @@ import type { CONTENT_TRANSLATION_REVISION_OPERATIONS, } from "./const"; import type { RichTextDocument } from "./rich-text/document"; +import type { ContentId } from "./ids"; export type ContentRevisionOperation = (typeof CONTENT_REVISION_OPERATIONS)[number]; @@ -21,8 +22,10 @@ export interface ContentActor { export type ContentSnapshotScalar = boolean | null | number | string; export type ContentSnapshotValue = + | ContentId[] + // A to-many reference: numbers for users, files and serial targets, strings + // for `uuid` and `bigint` targets. | ContentSnapshotScalar - | number[] | Record | Record[] | RichTextDocument; @@ -32,7 +35,8 @@ export interface ContentRevisionSnapshot { createdAt: string; /** Every declared field, by name. */ fields: Record; - id: number; + /** The record: a number for `serial`, the canonical string otherwise. */ + id: ContentId; /** Present only for a content type with the publication lifecycle. */ publication?: { publishedAt: null | string; status: string }; schemaVersion: number; @@ -52,7 +56,8 @@ export interface ContentTranslationRevisionSnapshot { createdAt: string; /** Every *localized* field, by name. */ fields: Record; - itemId: number; + /** A number for a `serial` record, the canonical string otherwise. */ + itemId: ContentId; languageId: number; /** The canonical `core_languages.code` at the time of the mutation. */ locale: string; diff --git a/packages/vitnode/src/content/schemas.ts b/packages/vitnode/src/content/schemas.ts index 93edafd888..34e8685510 100644 --- a/packages/vitnode/src/content/schemas.ts +++ b/packages/vitnode/src/content/schemas.ts @@ -1,5 +1,6 @@ import { z } from "zod"; +import type { ContentIdOf, ContentIdStrategy } from "./ids"; import type { AnyContentTypeDefinition, ContentCreateInput, @@ -36,6 +37,7 @@ import { isFilterableFieldKind, } from "./const"; import { zodContentFileDescriptor } from "./files"; +import { contentIdSchema, parseContentId } from "./ids"; import { contentLocalizationDisabled, partitionContentFields, @@ -63,6 +65,17 @@ const DISABLED_PUBLIC_API: ResolvedContentPublicApiConfig = { slugField: "", }; +/** + * The `{id}` path parameter's schema. A `serial` content type keeps exactly the + * coerced number it always had, so its OpenAPI document does not move; a + * `uuid` or `bigint` one validates the canonical string. + */ +export type ContentIdParamSchemaOf = [ + ContentIdOf, +] extends [number] + ? z.ZodCoercedNumber + : z.ZodType>; + export interface ContentTranslationSchemas< TDefinition = AnyContentTypeDefinition, > { @@ -78,7 +91,10 @@ export interface ContentTranslationSchemas< */ form: z.ZodObject; /** Path parameters for a translation route: the item, then the locale. */ - params: z.ZodObject<{ id: z.ZodCoercedNumber; locale: z.ZodString }>; + params: z.ZodObject<{ + id: ContentIdParamSchemaOf; + locale: z.ZodString; + }>; /** One translation as it comes back: metadata plus `values`. */ select: z.ZodObject; /** One translation without its values - what the list route returns. */ @@ -108,7 +124,7 @@ export interface ContentSchemas { /** `orderBy` allowlist plus direction. */ order: z.ZodObject; /** Path parameters for the detail/update/delete routes. */ - params: z.ZodObject<{ id: z.ZodCoercedNumber }>; + params: z.ZodObject<{ id: ContentIdParamSchemaOf }>; /** * Equality filters the public list route accepts, one per * `publicApi.filterableFields`. Empty when public exposure is off. @@ -167,9 +183,65 @@ const numberSchema = (fieldValue: { return schema; }; -/** Row identifiers are always positive integers, whatever the field kind. */ +/** + * A `core_users.id`, a `core_files.id`, a repeatable child's own id, or a serial + * content record's id: a positive integer. + */ const referenceSchema = (): z.ZodNumber => z.number().int().positive(); +/** One record identifier under `strategy`, as it crosses JSON. */ +const recordIdSchema = (strategy: ContentIdStrategy): z.ZodType => + // `serial` keeps the exact schema it always had, so a serial content type's + // generated forms and OpenAPI document are unchanged. + strategy === "serial" ? referenceSchema() : contentIdSchema(strategy); + +/** One record identifier under `strategy`, as a response carries it. */ +const recordIdOutputSchema = (strategy: ContentIdStrategy): z.ZodType => + strategy === "serial" ? z.number() : z.string(); + +/** + * The id strategy a relation's values follow, if it can be known while the + * schemas are being built: the owner's for a self-relation, the target's when + * the target module has already loaded. `null` means "not yet" - a target in a + * module that imports this one - and the caller defers the decision to the + * first parse with `z.lazy`. + */ +const relationStrategyNow = ( + fieldValue: ContentFieldDescriptor, + owner: ContentIdStrategy, +): ContentIdStrategy | null => { + if (fieldValue.kind !== "relation") return "serial"; + if (fieldValue.self) return owner; + + try { + return ( + (fieldValue.target() as undefined | { idStrategy?: ContentIdStrategy }) + ?.idStrategy ?? null + ); + } catch { + // A target still in its temporal dead zone - a circular import. + return null; + } +}; + +const relationStrategyLater = ( + fieldValue: ContentFieldDescriptor, +): ContentIdStrategy => + fieldValue.kind === "relation" ? fieldValue.target().idStrategy : "serial"; + +/** {@link recordIdSchema} for a relation's target, resolved now or on first use. */ +const relationIdSchema = ( + fieldValue: ContentFieldDescriptor, + owner: ContentIdStrategy, + build: (strategy: ContentIdStrategy) => z.ZodType = recordIdSchema, +): z.ZodType => { + const strategy = relationStrategyNow(fieldValue, owner); + + return strategy === null + ? z.lazy(() => build(relationStrategyLater(fieldValue))) + : build(strategy); +}; + const blocksSelectSchema = (): z.ZodType => z.array(zodContentNode); const blocksInputSchema = (fieldValue: ContentFieldDescriptor): z.ZodType => @@ -184,7 +256,10 @@ const blocksInputSchema = (fieldValue: ContentFieldDescriptor): z.ZodType => ); /** The value as it leaves the API. */ -const baseSelectSchema = (fieldValue: ContentFieldDescriptor): z.ZodType => { +const baseSelectSchema = ( + fieldValue: ContentFieldDescriptor, + owner: ContentIdStrategy = "serial", +): z.ZodType => { switch (fieldValue.kind) { case "blocks": return blocksSelectSchema(); @@ -218,8 +293,8 @@ const baseSelectSchema = (fieldValue: ContentFieldDescriptor): z.ZodType => { return numberSchema(fieldValue); case "relation": return fieldValue.multiple - ? z.array(referenceSchema()) - : referenceSchema(); + ? z.array(relationIdSchema(fieldValue, owner)) + : relationIdSchema(fieldValue, owner); case "repeatable": { const inner = contentInnerFields(fieldValue); @@ -257,7 +332,10 @@ const baseSelectSchema = (fieldValue: ContentFieldDescriptor): z.ZodType => { }; /** The value as it arrives from a client. `dateTime` is an ISO 8601 string. */ -const baseInputSchema = (fieldValue: ContentFieldDescriptor): z.ZodType => { +const baseInputSchema = ( + fieldValue: ContentFieldDescriptor, + owner: ContentIdStrategy = "serial", +): z.ZodType => { if (fieldValue.kind === "dateTime") return z.iso.datetime(); if (fieldValue.kind === "richText") { return createRichTextDocumentSchema({ @@ -266,7 +344,7 @@ const baseInputSchema = (fieldValue: ContentFieldDescriptor): z.ZodType => { }); } - return baseSelectSchema(fieldValue); + return baseSelectSchema(fieldValue, owner); }; const applyNullable = ( @@ -300,7 +378,10 @@ const applyPresence = ( return schema.optional(); }; -const relationSetSchema = (fieldValue: ContentFieldDescriptor): z.ZodType => { +const relationSetSchema = ( + fieldValue: ContentFieldDescriptor, + owner: ContentIdStrategy = "serial", +): z.ZodType => { const min = (fieldValue as { min?: number }).min; // A file collection carries its own ceiling and defaults to a much lower one: // every entry is a stored object the record pins against deletion, where a @@ -309,7 +390,13 @@ const relationSetSchema = (fieldValue: ContentFieldDescriptor): z.ZodType => { fieldValue.kind === "file" ? contentFileCollectionMax(fieldValue) : CONTENT_RELATION_COLLECTION_MAX; - let schema = z.array(referenceSchema()).max(max); + let schema = z + .array( + fieldValue.kind === "relation" + ? relationIdSchema(fieldValue, owner) + : referenceSchema(), + ) + .max(max); if (min !== undefined) schema = schema.min(min); return schema.refine(value => new Set(value).size === value.length, { @@ -384,6 +471,7 @@ const groupPatchSchema = (fieldValue: ContentFieldDescriptor): z.ZodType => { const inputShape = ( fields: ContentFieldMap, names: readonly string[], + owner: ContentIdStrategy = "serial", ): z.ZodRawShape => Object.fromEntries( names.map(name => { @@ -401,13 +489,13 @@ const inputShape = ( return [name, repeatableSchema(fieldValue).default([])]; } if (isContentReferenceCollection(fieldValue)) { - return [name, relationSetSchema(fieldValue).default([])]; + return [name, relationSetSchema(fieldValue, owner).default([])]; } return [ name, applyPresence( - applyNullable(baseInputSchema(fieldValue), fieldValue), + applyNullable(baseInputSchema(fieldValue, owner), fieldValue), fieldValue, ), ]; @@ -422,6 +510,7 @@ export const contentFieldValuesObject = ( const updateShape = ( fields: ContentFieldMap, names: readonly string[], + owner: ContentIdStrategy = "serial", ): z.ZodRawShape => Object.fromEntries( names.map(name => { @@ -437,12 +526,15 @@ const updateShape = ( return [name, repeatableSchema(fieldValue).optional()]; } if (isContentReferenceCollection(fieldValue)) { - return [name, relationSetSchema(fieldValue).optional()]; + return [name, relationSetSchema(fieldValue, owner).optional()]; } return [ name, - applyNullable(baseInputSchema(fieldValue), fieldValue).optional(), + applyNullable( + baseInputSchema(fieldValue, owner), + fieldValue, + ).optional(), ]; }), ); @@ -455,7 +547,37 @@ export const contentFieldUpdateSchema = ( ? undefined : (updateShape(fields, [name])[name] as z.ZodType); -const filterShape = (fields: ContentFieldMap): z.ZodRawShape => +/** + * A relation filter's identifier, read from a query string. A serial target + * keeps the coerced number it always had; the others parse the canonical + * string, and a target not loaded yet is parsed on first use. + */ +const relationFilterIdSchema = ( + fieldValue: ContentFieldDescriptor, + owner: ContentIdStrategy, +): z.ZodType => { + const strategy = relationStrategyNow(fieldValue, owner); + if (strategy === "serial") return z.coerce.number(); + + return z.string().transform((value, ctx) => { + const parsed = parseContentId( + strategy ?? relationStrategyLater(fieldValue), + value, + ); + if (parsed === null) { + ctx.addIssue({ code: "custom", message: "Invalid identifier." }); + + return z.NEVER; + } + + return parsed; + }); +}; + +const filterShape = ( + fields: ContentFieldMap, + owner: ContentIdStrategy = "serial", +): z.ZodRawShape => Object.fromEntries( Object.entries(fields) .filter(([, fieldValue]) => isFilterableFieldKind(fieldValue.kind)) @@ -468,7 +590,12 @@ const filterShape = (fields: ContentFieldMap): z.ZodRawShape => case "number": return [name, z.coerce.number().optional()]; case "relation": - case "user": + case "user": { + const id = + fieldValue.kind === "relation" + ? relationFilterIdSchema(fieldValue, owner) + : z.coerce.number(); + // A to-many reference filters by membership, and it arrives from a // query string as one identifier: `?categories=7`. The transform is // what turns it into the `{ contains }` object the query builder @@ -478,22 +605,25 @@ const filterShape = (fields: ContentFieldMap): z.ZodRawShape => return fieldValue.multiple ? [ name, - z.coerce - .number() + id .optional() .transform(value => value === undefined ? undefined : { contains: value }, ), ] - : [name, z.coerce.number().optional()]; + : [name, id.optional()]; + } default: return [name, z.string().optional()]; } }), ); -const publicRelationSchema = (): z.ZodObject => - z.object({ id: z.number() }); +const publicRelationSchema = ( + fieldValue: ContentFieldDescriptor, + owner: ContentIdStrategy, +): z.ZodObject => + z.object({ id: relationIdSchema(fieldValue, owner, recordIdOutputSchema) }); export const groupPublicLeafPaths = ( names: readonly string[], @@ -539,6 +669,7 @@ const publicSelectShape = ( fields: ContentFieldMap, publicApi: ResolvedContentPublicApiConfig, localization: ResolvedContentLocalizationConfig, + owner: ContentIdStrategy = "serial", ): z.ZodRawShape => ({ // The language actually served, which with a fallback is not always the one // that was asked for. `defineContentType` reserves the name on a localized @@ -548,7 +679,7 @@ const publicSelectShape = ( publicApi.fields .filter(name => splitContentFieldPath(name) === null) .map(name => { - if (name === "id") return [name, z.number()]; + if (name === "id") return [name, recordIdOutputSchema(owner)]; if (name === "createdAt" || name === "updatedAt") { return [name, z.date()]; } @@ -578,14 +709,24 @@ const publicSelectShape = ( // `{ id }` objects: the single-relation wrapper exists so a `null` // relation is distinguishable from a missing key, and an empty array // already says that on its own. - if (fieldValue.multiple) return [name, z.array(z.number())]; + if (fieldValue.multiple) { + return [ + name, + z.array( + relationIdSchema(fieldValue, owner, recordIdOutputSchema), + ), + ]; + } - const relation = publicRelationSchema(); + const relation = publicRelationSchema(fieldValue, owner); return [name, fieldValue.nullable ? relation.nullable() : relation]; } - return [name, applyNullable(baseSelectSchema(fieldValue), fieldValue)]; + return [ + name, + applyNullable(baseSelectSchema(fieldValue, owner), fieldValue), + ]; }), ), ...Object.fromEntries( @@ -596,13 +737,19 @@ const publicSelectShape = ( ), }); +/** The `{id}` path parameter of `strategy`. See {@link ContentIdParamSchemaOf}. */ +const idParamSchema = (strategy: ContentIdStrategy): z.ZodType => + strategy === "serial" ? z.coerce.number() : contentIdSchema(strategy); + const buildTranslationSchemas = ({ admin, + idStrategy, localizedFields, localization, publication, }: { admin: ResolvedContentAdminConfig; + idStrategy: ContentIdStrategy; localization: ResolvedContentLocalizationConfig; localizedFields: ContentFieldMap; publication: boolean; @@ -611,9 +758,9 @@ const buildTranslationSchemas = ({ const names = Object.keys(localizedFields); - const create = z.strictObject(inputShape(localizedFields, names)); + const create = z.strictObject(inputShape(localizedFields, names, idStrategy)); const update = z - .strictObject(updateShape(localizedFields, names)) + .strictObject(updateShape(localizedFields, names, idStrategy)) .refine(value => Object.keys(value).length > 0, { message: "Provide at least one localized field to update.", }); @@ -624,7 +771,10 @@ const buildTranslationSchemas = ({ // `publish` / `unpublish`. const selectMeta = z.object({ createdAt: z.date(), - itemId: z.number().int().positive(), + itemId: + idStrategy === "serial" + ? z.number().int().positive() + : recordIdOutputSchema(idStrategy), languageId: z.number().int().positive(), locale: z.string(), ...(publication @@ -642,7 +792,7 @@ const buildTranslationSchemas = ({ names.map(name => [ name, applyNullable( - baseSelectSchema(localizedFields[name]), + baseSelectSchema(localizedFields[name], idStrategy), localizedFields[name], ), ]), @@ -661,15 +811,16 @@ const buildTranslationSchemas = ({ inputShape( localizedFields, admin.form.fields.filter(name => localizedFields[name] !== undefined), + idStrategy, ), ), params: z.object({ - id: z.coerce.number(), + id: idParamSchema(idStrategy), // Loose on purpose, like `publicParams.slug`: an unknown locale and a // malformed one are both answered the same way, and the value is a bound // parameter rather than an identifier. locale: z.string().min(1).max(CONTENT_LOCALE_MAX_LENGTH), - }), + }) as unknown as ContentTranslationSchemas["params"], select: selectMeta.extend({ values }), selectMeta, update: update as unknown as z.ZodType< @@ -693,6 +844,7 @@ export const buildContentSchemas = ({ advanced = contentAdvancedDisabled(), editorial = false, fields, + idStrategy = "serial", localization = contentLocalizationDisabled(), publicApi = DISABLED_PUBLIC_API, publication = false, @@ -703,6 +855,8 @@ export const buildContentSchemas = ({ editorial?: boolean; /** Every declared field. Partitioned here, so no caller has to. */ fields: ContentFieldMap; + /** The content type's own id strategy: `id`, `{id}` and self-relations follow it. */ + idStrategy?: ContentIdStrategy; localization?: ResolvedContentLocalizationConfig; publicApi?: ResolvedContentPublicApiConfig; publication?: boolean; @@ -748,11 +902,14 @@ export const buildContentSchemas = ({ : {}; const selectShape: z.ZodRawShape = { - id: z.number(), + id: recordIdOutputSchema(idStrategy), ...Object.fromEntries( fieldNames.map(name => [ name, - applyNullable(baseSelectSchema(sharedFields[name]), sharedFields[name]), + applyNullable( + baseSelectSchema(sharedFields[name], idStrategy), + sharedFields[name], + ), ]), ), ...publicationSelectShape, @@ -765,9 +922,11 @@ export const buildContentSchemas = ({ // `strictObject` blocks mass assignment: an unknown key is an error, not // something quietly stripped. System columns are absent from the shape, so // they can never be set from a request. - const create = z.strictObject(inputShape(writableFields, writableNames)); + const create = z.strictObject( + inputShape(writableFields, writableNames, idStrategy), + ); const update = z - .strictObject(updateShape(writableFields, writableNames)) + .strictObject(updateShape(writableFields, writableNames, idStrategy)) .refine(value => Object.keys(value).length > 0, { message: "Provide at least one field to update.", }); @@ -781,7 +940,7 @@ export const buildContentSchemas = ({ const selectObject = z.object(selectShape); const publicSelectObject = z.object( - publicSelectShape(fields, publicApi, localization), + publicSelectShape(fields, publicApi, localization, idStrategy), ); const publicFilterable = new Set(publicApi.filterableFields); // Derived from the same `filterShape`, then narrowed to the configured @@ -795,7 +954,7 @@ export const buildContentSchemas = ({ // over the base table. const publicFilters = z.object( Object.fromEntries( - Object.entries(filterShape(fields)).filter(([name]) => + Object.entries(filterShape(fields, idStrategy)).filter(([name]) => publicFilterable.has(name), ), ), @@ -810,7 +969,10 @@ export const buildContentSchemas = ({ [ ...advanced.junctions.map(entry => entry.field), ...advanced.repeatables.map(entry => entry.field), - ].map(name => [name, baseSelectSchema(collectionFields[name])]), + ].map(name => [ + name, + baseSelectSchema(collectionFields[name], idStrategy), + ]), ), ), // The shapes are assembled in a loop, so their Zod types are erased. @@ -819,7 +981,7 @@ export const buildContentSchemas = ({ // casts. `buildContentSchemas` is covered by `schemas.test-d.ts`. create: create as unknown as z.ZodType>, filters: z.object({ - ...filterShape(writableFields), + ...filterShape(writableFields, idStrategy), ...(publication ? { status: z.enum(CONTENT_PUBLICATION_STATUSES).optional() } : {}), @@ -835,13 +997,16 @@ export const buildContentSchemas = ({ inputShape( writableFields, admin.form.fields.filter(name => writableFields[name] !== undefined), + idStrategy, ), ), order: z.object({ order: z.enum(["asc", "desc"]).optional(), orderBy: z.enum(orderable as [string, ...string[]]).optional(), }), - params: z.object({ id: z.coerce.number() }), + params: z.object({ + id: idParamSchema(idStrategy), + }) as unknown as ContentSchemas["params"], publicFilters, publicOrder: z.object({ order: z.enum(["asc", "desc"]).optional(), @@ -865,6 +1030,7 @@ export const buildContentSchemas = ({ selectObject, translation: buildTranslationSchemas({ admin, + idStrategy, localization, localizedFields, publication, diff --git a/packages/vitnode/src/content/search.ts b/packages/vitnode/src/content/search.ts index af3ddebf35..018328f055 100644 --- a/packages/vitnode/src/content/search.ts +++ b/packages/vitnode/src/content/search.ts @@ -1,7 +1,9 @@ +import type { ContentId } from "./ids"; import type { ContentLocaleRouting } from "./public-url"; import type { AnyContentTypeDefinition } from "./types"; import { CONTENT_SEARCH_SLUG_PLACEHOLDER } from "./const"; +import { contentIdKey } from "./ids"; import { isContentReferenceCollection, splitContentFieldPath } from "./paths"; import { contentPublicHref, contentPublicUrl } from "./public-url"; @@ -40,12 +42,12 @@ export const contentSearchUrl = ({ export const contentSearchDocumentId = ( definition: AnyContentTypeDefinition, - id: number, + id: ContentId, locale?: string, ): string => locale === undefined || locale === "" - ? `${definition.id}:${id}` - : `${definition.id}:${id}:${locale}`; + ? `${definition.id}:${contentIdKey(id)}` + : `${definition.id}:${contentIdKey(id)}:${locale}`; /** * Every field whose value the search document is built from, including the slug diff --git a/packages/vitnode/src/content/server/advanced-store.ts b/packages/vitnode/src/content/server/advanced-store.ts index c4d558b604..2eba2cdc51 100644 --- a/packages/vitnode/src/content/server/advanced-store.ts +++ b/packages/vitnode/src/content/server/advanced-store.ts @@ -12,6 +12,7 @@ import { } from "drizzle-orm"; import { getTableConfig } from "drizzle-orm/pg-core"; +import type { ContentId, ContentIdStrategy } from "../ids"; import type { AnyContentTypeDefinition, ContentRelationFilter } from "../types"; import type { ContentDatabase } from "./service"; import type { ContentAdvancedTables } from "./types"; @@ -21,6 +22,13 @@ import { CONTENT_COLLECTION_FIRST_POSITION, } from "../const"; import { ContentAdvancedInputError, ContentEngineError } from "../errors"; +import { + compareContentIds, + contentIdKey, + contentRelationStrategy, + parseContentId, + requireContentId, +} from "../ids"; import { asContentReferenceCollection, contentInnerFields, @@ -31,10 +39,20 @@ import { toColumnValues } from "./query"; /** One repeatable child, as it is written back. */ type ChildValues = Record; +/** + * The collection half of every content record: junction rows and repeatable + * children. + * + * Owner ids are the content type's own (`ContentId` in its canonical form - a + * number for `serial`, a string for `uuid` and `bigint`); a relation's target + * ids follow the *target's* strategy; a user or file id is a number; and a + * repeatable child's own id is always a `serial` number. `loadMany` keys its + * map by the canonical owner id, exactly as the base row's `id` reads. + */ export interface ContentAdvancedStore { diff: ( tx: ContentDatabase, - itemId: number, + itemId: ContentId, patch: Record, ) => Promise; /** Whether the content type declares any advanced collection at all. */ @@ -43,16 +61,16 @@ export interface ContentAdvancedStore { readonly fields: string[]; load: ( - itemId: number, + itemId: ContentId, database: ContentDatabase, only?: readonly string[], ) => Promise>; loadMany: ( - itemIds: readonly number[], + itemIds: readonly ContentId[], database: ContentDatabase, only?: readonly string[], - ) => Promise>>; + ) => Promise>>; membershipCondition: ( field: string, @@ -61,10 +79,10 @@ export interface ContentAdvancedStore { prepareRestore: ( tx: ContentDatabase, - itemId: number, + itemId: ContentId, patch: Record, ) => Promise<{ - missingRelations: { field: string; ids: number[] }[]; + missingRelations: { field: string; ids: ContentId[] }[]; patch: Record; }>; @@ -72,7 +90,7 @@ export interface ContentAdvancedStore { /** Applies a patch's collection half. Returns the fields that moved. */ write: ( tx: ContentDatabase, - itemId: number, + itemId: ContentId, patch: Record, ) => Promise; } @@ -80,20 +98,31 @@ export interface ContentAdvancedStore { /** The row shape a relation's junction table holds, in write order. */ interface JunctionRow { createdAt?: Date; - relatedItemId: number; + relatedItemId: ContentId; } const sameJunction = ( current: readonly JunctionRow[], - desired: readonly number[], + desired: readonly ContentId[], ): boolean => current.length === desired.length && - current.every((row, index) => row.relatedItemId === desired[index]); + current.every( + (row, index) => + contentIdKey(row.relatedItemId) === contentIdKey(desired[index]), + ); +/** + * An unordered collection is stored in the order Postgres sorts its key in: + * numerically for `serial` and `bigint`, lexically for `uuid`. + */ const normalizeTargets = ( - ids: readonly number[], + ids: readonly ContentId[], ordered: boolean, -): number[] => (ordered ? [...ids] : [...ids].sort((a, b) => a - b)); + strategy: ContentIdStrategy, +): ContentId[] => + ordered + ? [...ids] + : [...ids].sort((a, b) => compareContentIds(strategy, a, b)); const sameChildValues = ( leaves: readonly string[], @@ -133,6 +162,44 @@ export const createContentAdvancedStore = < ); const baseColumns = table as unknown as Record; + /** An owner id in its canonical form - what the base row's `id` reads as. */ + const ownerId = (value: unknown): ContentId => + requireContentId(definition.idStrategy, value, contentTypeId); + + /** The id strategy a collection's targets follow. Users and files are serial. */ + const targetStrategies = new Map(); + const targetStrategy = (field: string): ContentIdStrategy => { + const cached = targetStrategies.get(field); + if (cached) return cached; + + const fieldValue = fields[field]; + const strategy = + fieldValue.kind === "relation" + ? contentRelationStrategy(definition, fieldValue) + : "serial"; + targetStrategies.set(field, strategy); + + return strategy; + }; + + /** A target id read back from a junction, in the target's canonical form. */ + const targetId = (field: string, value: unknown): ContentId => + requireContentId(targetStrategy(field), value, contentTypeId); + + /** The canonical ids of a caller's list; anything unparseable is dropped. */ + const targetIds = ( + field: string, + values: readonly unknown[], + ): ContentId[] => { + const ids: ContentId[] = []; + for (const value of values) { + const parsed = parseContentId(targetStrategy(field), value); + if (parsed !== null) ids.push(parsed); + } + + return ids; + }; + const junctionOf = ( field: string, ): null | { columns: Record; table: PgTable } => { @@ -185,7 +252,7 @@ export const createContentAdvancedStore = < const assertTargetsExist = async ( tx: ContentDatabase, field: string, - ids: readonly number[], + ids: readonly ContentId[], ): Promise => { if (ids.length === 0) return; @@ -197,8 +264,10 @@ export const createContentAdvancedStore = < .from(getColumnTable(target)) .where(inArray(target, [...ids])); - const found = new Set(rows.map(row => Number(row.id))); - const missing = ids.filter(id => !found.has(id)); + const found = new Set( + rows.map(row => contentIdKey(targetId(field, row.id))), + ); + const missing = ids.filter(id => !found.has(contentIdKey(id))); if (missing.length === 0) return; // The noun follows the kind, because the two mean different things to @@ -224,13 +293,17 @@ export const createContentAdvancedStore = < }); }; + /** + * Junction rows per owner, keyed by storage key so a caller's `7` and the + * driver's `7` - or a bigint and its digits - always meet. + */ const readJunction = async ( field: string, - itemIds: readonly number[], + itemIds: readonly ContentId[], database: ContentDatabase, - ): Promise> => { + ): Promise> => { const junction = junctionOf(field); - const result = new Map(); + const result = new Map(); if (!junction || itemIds.length === 0) return result; const rows = await database @@ -244,13 +317,13 @@ export const createContentAdvancedStore = < .orderBy(asc(junction.columns.itemId), asc(junction.columns.position)); for (const row of rows) { - const itemId = Number(row.itemId); - const list = result.get(itemId) ?? []; + const itemKey = contentIdKey(ownerId(row.itemId)); + const list = result.get(itemKey) ?? []; list.push({ createdAt: row.createdAt instanceof Date ? row.createdAt : undefined, - relatedItemId: Number(row.relatedItemId), + relatedItemId: targetId(field, row.relatedItemId), }); - result.set(itemId, list); + result.set(itemKey, list); } return result; @@ -258,11 +331,11 @@ export const createContentAdvancedStore = < const readChildren = async ( field: string, - itemIds: readonly number[], + itemIds: readonly ContentId[], database: ContentDatabase, - ): Promise> => { + ): Promise> => { const child = childOf(field); - const result = new Map(); + const result = new Map(); if (!child || itemIds.length === 0) return result; const leaves = leafNamesOf(field); @@ -278,13 +351,14 @@ export const createContentAdvancedStore = < for (const row of rows) { const values = row as ChildValues; - const itemId = Number(values.itemId); - const list = result.get(itemId) ?? []; + const itemKey = contentIdKey(ownerId(values.itemId)); + const list = result.get(itemKey) ?? []; list.push({ + // A child's own id is serial whatever the owner's strategy. id: Number(values.id), ...Object.fromEntries(leaves.map(leaf => [leaf, values[leaf]])), }); - result.set(itemId, list); + result.set(itemKey, list); } return result; @@ -303,7 +377,7 @@ export const createContentAdvancedStore = < const settlePositions = async ( tx: ContentDatabase, target: { columns: Record; table: PgTable }, - itemId: number, + itemId: ContentId, ): Promise => { await tx .update(target.table) @@ -317,16 +391,17 @@ export const createContentAdvancedStore = < const writeRelation = async ( tx: ContentDatabase, field: string, - itemId: number, - desired: readonly number[], + itemId: ContentId, + desired: readonly ContentId[], ): Promise => { const junction = junctionOf(field); if (!junction) return; - const current = (await readJunction(field, [itemId], tx)).get(itemId) ?? []; - const keep = new Set(desired); + const current = + (await readJunction(field, [itemId], tx)).get(contentIdKey(itemId)) ?? []; + const keep = new Set(desired.map(contentIdKey)); const removed = current - .filter(row => !keep.has(row.relatedItemId)) + .filter(row => !keep.has(contentIdKey(row.relatedItemId))) .map(row => row.relatedItemId); if (removed.length > 0) { @@ -342,12 +417,12 @@ export const createContentAdvancedStore = < const existing = new Set( current - .filter(row => keep.has(row.relatedItemId)) - .map(row => row.relatedItemId), + .filter(row => keep.has(contentIdKey(row.relatedItemId))) + .map(row => contentIdKey(row.relatedItemId)), ); for (const [index, relatedItemId] of desired.entries()) { - if (!existing.has(relatedItemId)) continue; + if (!existing.has(contentIdKey(relatedItemId))) continue; await tx .update(junction.table) @@ -362,7 +437,7 @@ export const createContentAdvancedStore = < const inserted = desired .map((relatedItemId, index) => ({ index, relatedItemId })) - .filter(entry => !existing.has(entry.relatedItemId)); + .filter(entry => !existing.has(contentIdKey(entry.relatedItemId))); if (inserted.length > 0) { await tx.insert(junction.table).values( @@ -380,7 +455,7 @@ export const createContentAdvancedStore = < const writeRepeatable = async ( tx: ContentDatabase, field: string, - itemId: number, + itemId: ContentId, desired: readonly ChildValues[], ): Promise => { const child = childOf(field); @@ -388,7 +463,8 @@ export const createContentAdvancedStore = < const leaves = leafNamesOf(field); const inner = contentInnerFields(fields[field]); - const current = (await readChildren(field, [itemId], tx)).get(itemId) ?? []; + const current = + (await readChildren(field, [itemId], tx)).get(contentIdKey(itemId)) ?? []; const currentIds = new Set(current.map(row => Number(row.id))); // A child id the caller sent that does not belong to this record is a @@ -447,7 +523,7 @@ export const createContentAdvancedStore = < const plan = async ( tx: ContentDatabase, - itemId: number, + itemId: ContentId, patch: Record, ): Promise< { desired: unknown; field: string; kind: "relation" | "repeatable" }[] @@ -466,9 +542,14 @@ export const createContentAdvancedStore = < if (relation) { if (!Array.isArray(value)) continue; - const desired = normalizeTargets(value as number[], relation.ordered); + const desired = normalizeTargets( + targetIds(field, value), + relation.ordered, + targetStrategy(field), + ); const current = - (await readJunction(field, [itemId], tx)).get(itemId) ?? []; + (await readJunction(field, [itemId], tx)).get(contentIdKey(itemId)) ?? + []; if (sameJunction(current, desired)) continue; @@ -481,7 +562,8 @@ export const createContentAdvancedStore = < const desired = value as ChildValues[]; const current = - (await readChildren(field, [itemId], tx)).get(itemId) ?? []; + (await readChildren(field, [itemId], tx)).get(contentIdKey(itemId)) ?? + []; const leaves = leafNamesOf(field); const unchanged = @@ -537,17 +619,19 @@ export const createContentAdvancedStore = < fields: collectionNames, load: async (itemId, database, only) => { + const itemKey = contentIdKey(itemId); const loaded = await Promise.all( selected(only).map(async field => { if (tables.junctions[field]) { const rows = - (await readJunction(field, [itemId], database)).get(itemId) ?? []; + (await readJunction(field, [itemId], database)).get(itemKey) ?? + []; return [field, rows.map(row => row.relatedItemId)] as const; } const rows = - (await readChildren(field, [itemId], database)).get(itemId) ?? []; + (await readChildren(field, [itemId], database)).get(itemKey) ?? []; return [field, rows] as const; }), @@ -558,20 +642,21 @@ export const createContentAdvancedStore = < loadMany: async (itemIds, database, only) => { const wanted = selected(only); - const result = new Map>(); + // Keyed by the caller's own (canonical) ids, read through storage keys. + const result = new Map>(); + const byKey = new Map>(); for (const itemId of itemIds) { - result.set( - itemId, - Object.fromEntries(wanted.map(field => [field, []])), - ); + const entry = Object.fromEntries(wanted.map(field => [field, []])); + result.set(itemId, entry); + byKey.set(contentIdKey(itemId), entry); } if (itemIds.length === 0) return result; for (const field of wanted) { if (tables.junctions[field]) { const rows = await readJunction(field, itemIds, database); - for (const [itemId, list] of rows) { - const entry = result.get(itemId); + for (const [itemKey, list] of rows) { + const entry = byKey.get(itemKey); if (!entry) continue; entry[field] = list.map(row => row.relatedItemId); } @@ -579,8 +664,8 @@ export const createContentAdvancedStore = < } const rows = await readChildren(field, itemIds, database); - for (const [itemId, list] of rows) { - const entry = result.get(itemId); + for (const [itemKey, list] of rows) { + const entry = byKey.get(itemKey); if (!entry) continue; entry[field] = list; } @@ -591,14 +676,14 @@ export const createContentAdvancedStore = < prepareRestore: async (tx, itemId, patch) => { const prepared: Record = { ...patch }; - const missingRelations: { field: string; ids: number[] }[] = []; + const missingRelations: { field: string; ids: ContentId[] }[] = []; for (const field of collectionNames) { const value = prepared[field]; if (!Array.isArray(value)) continue; if (tables.junctions[field]) { - const ids = (value as number[]).filter(id => Number.isInteger(id)); + const ids = targetIds(field, value); const target = relationTargetColumn(field); if (!target || ids.length === 0) continue; @@ -606,8 +691,10 @@ export const createContentAdvancedStore = < .select({ id: target }) .from(getColumnTable(target)) .where(inArray(target, ids)); - const found = new Set(rows.map(row => Number(row.id))); - const missing = ids.filter(id => !found.has(id)); + const found = new Set( + rows.map(row => contentIdKey(targetId(field, row.id))), + ); + const missing = ids.filter(id => !found.has(contentIdKey(id))); if (missing.length > 0) missingRelations.push({ field, ids: missing }); @@ -618,7 +705,8 @@ export const createContentAdvancedStore = < // dropping `id` is exactly the "create a new one" branch of the write // protocol, so the entry comes back with its values and a fresh id. const current = - (await readChildren(field, [itemId], tx)).get(itemId) ?? []; + (await readChildren(field, [itemId], tx)).get(contentIdKey(itemId)) ?? + []; const known = new Set(current.map(row => Number(row.id))); prepared[field] = (value as ChildValues[]).map(row => { @@ -644,12 +732,15 @@ export const createContentAdvancedStore = < ); } - if (!Number.isInteger(filter.contains)) return undefined; + // A value that cannot name a target matches nothing - and never reaches + // Postgres as a cast error. + const contains = parseContentId(targetStrategy(field), filter.contains); + if (contains === null) return sql`false`; // Correlated, so the planner uses the junction's primary key and stops at // the first matching row - and the outer query needs no `DISTINCT` to keep // one record from appearing once per matching target. - return sql`exists (select 1 from ${junction.table} where ${junction.columns.itemId} = ${baseColumns.id} and ${junction.columns.relatedItemId} = ${filter.contains})`; + return sql`exists (select 1 from ${junction.table} where ${junction.columns.itemId} = ${baseColumns.id} and ${junction.columns.relatedItemId} = ${contains})`; }, targetTable: field => { @@ -663,7 +754,7 @@ export const createContentAdvancedStore = < for (const change of changes) { if (change.kind === "relation") { - const desired = change.desired as number[]; + const desired = change.desired as ContentId[]; await assertTargetsExist(tx, change.field, desired); await writeRelation(tx, change.field, itemId, desired); continue; diff --git a/packages/vitnode/src/content/server/advanced-tables.ts b/packages/vitnode/src/content/server/advanced-tables.ts index 9cb7a34f95..6def2fc57f 100644 --- a/packages/vitnode/src/content/server/advanced-tables.ts +++ b/packages/vitnode/src/content/server/advanced-tables.ts @@ -14,6 +14,7 @@ import { uniqueIndex, } from "drizzle-orm/pg-core"; +import type { ContentIdStrategy } from "../ids"; import type { AnyContentTypeDefinition, ContentFieldMap, @@ -35,29 +36,39 @@ import { contentInnerFields, contentStorageColumns, } from "../paths"; -import { buildContentColumn } from "./column-builders"; +import { buildContentColumn, buildIdForeignKey } from "./column-builders"; +import { + assertContentReferenceType, + contentRelationBuildStrategy, +} from "./table"; export const createContentJunctionTable = ({ contentTypeId, field, itemReference, + itemStrategy = "serial", onDelete, positionIndexName, primaryKeyName, relatedIndexName, relatedReference, + relatedStrategy = "serial", tableName, }: { contentTypeId: string; field: string; itemReference: ColumnReferenceThunk; + /** The owner's id strategy, which `itemId` follows. */ + itemStrategy?: ContentIdStrategy; onDelete: ContentOnDelete; positionIndexName: string; primaryKeyName: string; relatedIndexName: string; relatedReference: ColumnReferenceThunk; + /** The target's id strategy, which `relatedItemId` follows. Users and files are `serial`. */ + relatedStrategy?: ContentIdStrategy; tableName: string; -}): ContentJunctionTable => { +}): ContentJunctionTable => { if (onDelete === "set null") { throw new ContentEngineError( `Relation field "${field}" cannot be \`onDelete: "set null"\`: a junction row has no nullable column to set.`, @@ -66,10 +77,10 @@ export const createContentJunctionTable = ({ } const columns: Record = { - itemId: integer() + itemId: buildIdForeignKey(itemStrategy) .notNull() .references(itemReference, { onDelete: "cascade", onUpdate: "cascade" }), - relatedItemId: integer() + relatedItemId: buildIdForeignKey(relatedStrategy) .notNull() .references(relatedReference, { onDelete, onUpdate: "cascade" }), position: integer().notNull(), @@ -96,25 +107,28 @@ export const createContentJunctionTable = ({ index(relatedIndexName).on(columnMap.relatedItemId), ]; }, - ) as unknown as ContentJunctionTable; + ) as unknown as ContentJunctionTable; }; export const createContentRepeatableTable = ({ contentTypeId, fields, itemReference, + itemStrategy = "serial", positionIndexName, tableName, }: { contentTypeId: string; fields: ContentFieldMap; itemReference: ColumnReferenceThunk; + /** The owner's id strategy, which `itemId` follows. The child's own `id` stays serial. */ + itemStrategy?: ContentIdStrategy; positionIndexName: string; tableName: string; -}): ContentRepeatableChildTable => { +}): ContentRepeatableChildTable => { const columns: Record = { id: serial().primaryKey(), - itemId: integer() + itemId: buildIdForeignKey(itemStrategy) .notNull() .references(itemReference, { onDelete: "cascade", onUpdate: "cascade" }), position: integer().notNull(), @@ -139,7 +153,7 @@ export const createContentRepeatableTable = ({ uniqueIndex(positionIndexName).on(columnMap.itemId, columnMap.position), ]; }, - ) as unknown as ContentRepeatableChildTable; + ) as unknown as ContentRepeatableChildTable; }; export const createContentAdvancedTables = < @@ -159,7 +173,7 @@ export const createContentAdvancedTables = < const referenceThunks = references as Record; const itemReference: ColumnReferenceThunk = () => baseColumns.id; - const junctions: Record = {}; + const junctions: ContentAdvancedTables["junctions"] = {}; for (const entry of advanced.junctions) { const fieldValue = asContentReferenceCollection(fields[entry.field]); if (!fieldValue) continue; @@ -188,10 +202,30 @@ export const createContentAdvancedTables = < ); } + const relatedStrategy = + fieldValue.kind === "relation" + ? contentRelationBuildStrategy(definition, fieldValue) + : "serial"; + const checkedRelatedReference: ColumnReferenceThunk = + fieldValue.kind === "relation" && !fieldValue.self + ? () => { + const column = relatedReference(); + assertContentReferenceType({ + column, + contentTypeId, + name: entry.field, + strategy: relatedStrategy, + }); + + return column; + } + : relatedReference; + junctions[entry.field] = createContentJunctionTable({ contentTypeId, field: entry.field, itemReference, + itemStrategy: definition.idStrategy, // A gallery entry is `restrict`, always, and not a per-field choice - the // same rule the single-file column follows. `cascade` would delete the // junction row because somebody tidied up the Files screen, silently @@ -203,12 +237,13 @@ export const createContentAdvancedTables = < positionIndexName: entry.positionIndexName, primaryKeyName: entry.primaryKeyName, relatedIndexName: entry.relatedIndexName, - relatedReference, + relatedReference: checkedRelatedReference, + relatedStrategy, tableName: entry.tableName, }); } - const repeatables: Record> = {}; + const repeatables: ContentAdvancedTables["repeatables"] = {}; for (const entry of advanced.repeatables) { repeatables[entry.field] = createContentRepeatableTable({ contentTypeId, @@ -218,6 +253,7 @@ export const createContentAdvancedTables = < // a column that silently never appears. fields: contentStorageColumns(contentInnerFields(fields[entry.field])), itemReference, + itemStrategy: definition.idStrategy, positionIndexName: entry.positionIndexName, tableName: entry.tableName, }); @@ -228,5 +264,7 @@ export const createContentAdvancedTables = < /** Column name -> Drizzle column on one generated collection table. */ export const contentCollectionTableColumns = ( - table: ContentJunctionTable | ContentRepeatableChildTable, + table: + | ContentJunctionTable + | ContentRepeatableChildTable, ): Record => table as unknown as Record; diff --git a/packages/vitnode/src/content/server/collection-api.ts b/packages/vitnode/src/content/server/collection-api.ts index 8dbc080be6..55521d5840 100644 --- a/packages/vitnode/src/content/server/collection-api.ts +++ b/packages/vitnode/src/content/server/collection-api.ts @@ -1,9 +1,11 @@ +import type { ContentId, ContentIdStrategy } from "../ids"; import type { AnyContentTypeDefinition } from "../types"; import { ContentEngineError } from "../errors"; +import { contentIdKey, contentIdsOf, parseContentId } from "../ids"; export type ContentCollectionRunner = ( - itemId: number, + itemId: ContentId, field: string, compute: (current: unknown[]) => unknown[], options: TOptions | undefined, @@ -11,14 +13,14 @@ export type ContentCollectionRunner = ( /** Reads one collection without locking. Used only by `get` and `list`. */ export type ContentCollectionReader = ( - itemId: number, + itemId: ContentId, field: string, options: unknown, ) => Promise; /** Replaces a whole collection. `set` needs no lock-then-read. */ export type ContentCollectionWriter = ( - itemId: number, + itemId: ContentId, field: string, next: readonly unknown[], options: TOptions | undefined, @@ -38,18 +40,21 @@ export const assertContentPermutation = ({ noun, }: { contentTypeId: string; - current: readonly number[]; + current: readonly ContentId[]; field: string; - next: readonly number[]; + next: readonly ContentId[]; noun: string; }): void => { - const before = [...current].sort((a, b) => a - b); - const after = [...new Set(next)].sort((a, b) => a - b); + // Compared by storage key, so a `bigint` target is never rounded and a + // number and its own digits are the same identifier. + const before = current.map(contentIdKey).sort(); + const nextKeys = next.map(contentIdKey); + const after = [...new Set(nextKeys)].sort(); const same = before.length === after.length && before.every((id, index) => id === after[index]); - if (same && next.length === new Set(next).size) return; + if (same && nextKeys.length === new Set(nextKeys).size) return; throw new ContentEngineError( `Reorder of "${field}" must list exactly the ${noun} ids it already has, once each. Use \`set\` to add or remove.`, @@ -57,9 +62,35 @@ export const assertContentPermutation = ({ ); }; -const asNumbers = (current: readonly unknown[]): number[] => +/** A repeatable child's own id - always `serial`, whatever the owner's strategy. */ +const asChildIds = (current: readonly unknown[]): number[] => current.map(value => Number(value)).filter(value => Number.isInteger(value)); +/** + * A caller's identifier in the target's canonical representation, so `"7"` and + * `7` - or a bigint and its digits - name the same entry. Refused outright when + * it cannot name a record of the target at all. + */ +const asTargetId = ( + strategy: ContentIdStrategy, + value: ContentId, + contentTypeId: string, + field: string, +): ContentId => { + const parsed = parseContentId(strategy, value); + if (parsed === null) { + throw new ContentEngineError( + `${JSON.stringify(value)} is not a valid identifier for "${field}".`, + { contentTypeId }, + ); + } + + return parsed; +}; + +const sameId = (left: ContentId, right: ContentId): boolean => + contentIdKey(left) === contentIdKey(right); + const asRows = (current: readonly unknown[]): Record[] => current.filter( (value): value is Record => @@ -70,71 +101,90 @@ export const buildContentRelationOperations = ({ api, contentTypeId, field, + strategy = "serial", }: { api: ContentCollectionApi; contentTypeId: string; field: string; -}) => ({ - add: async ( - itemId: number, - relatedItemId: number, - options?: TOptions, - ): Promise => - await api.run( - itemId, - field, - current => { - const ids = asNumbers(current); + /** The target's id strategy: a relation's target's, `serial` for users and files. */ + strategy?: ContentIdStrategy; +}) => { + const asIds = (current: readonly unknown[]): ContentId[] => + contentIdsOf(strategy, current); + const target = (value: ContentId): ContentId => + asTargetId(strategy, value, contentTypeId, field); - return ids.includes(relatedItemId) ? ids : [...ids, relatedItemId]; - }, - options, - ), + return { + add: async ( + itemId: ContentId, + relatedItemId: ContentId, + options?: TOptions, + ): Promise => { + const related = target(relatedItemId); - get: async (itemId: number, options?: unknown): Promise => - asNumbers(await api.read(itemId, field, options)), + return await api.run( + itemId, + field, + current => { + const ids = asIds(current); - remove: async ( - itemId: number, - relatedItemId: number, - options?: TOptions, - ): Promise => - await api.run( - itemId, - field, - current => asNumbers(current).filter(id => id !== relatedItemId), - options, - ), + return ids.some(id => sameId(id, related)) ? ids : [...ids, related]; + }, + options, + ); + }, - reorder: async ( - itemId: number, - relatedItemIds: readonly number[], - options?: TOptions, - ): Promise => - await api.run( - itemId, - field, - current => { - assertContentPermutation({ - contentTypeId, - current: asNumbers(current), - field, - next: relatedItemIds, - noun: "target", - }); + get: async (itemId: ContentId, options?: unknown): Promise => + asIds(await api.read(itemId, field, options)), - return [...relatedItemIds]; - }, - options, - ), + remove: async ( + itemId: ContentId, + relatedItemId: ContentId, + options?: TOptions, + ): Promise => { + const related = target(relatedItemId); - set: async ( - itemId: number, - relatedItemIds: readonly number[], - options?: TOptions, - ): Promise => - await api.write(itemId, field, relatedItemIds, options), -}); + return await api.run( + itemId, + field, + current => asIds(current).filter(id => !sameId(id, related)), + options, + ); + }, + + reorder: async ( + itemId: ContentId, + relatedItemIds: readonly ContentId[], + options?: TOptions, + ): Promise => { + const next = relatedItemIds.map(target); + + return await api.run( + itemId, + field, + current => { + assertContentPermutation({ + contentTypeId, + current: asIds(current), + field, + next, + noun: "target", + }); + + return next; + }, + options, + ); + }, + + set: async ( + itemId: ContentId, + relatedItemIds: readonly ContentId[], + options?: TOptions, + ): Promise => + await api.write(itemId, field, relatedItemIds, options), + }; +}; /** The six repeatable operations, for one field. Same locking, same no-op rule. */ export const buildContentRepeatableOperations = ({ @@ -147,7 +197,7 @@ export const buildContentRepeatableOperations = ({ field: string; }) => ({ create: async ( - itemId: number, + itemId: ContentId, values: Record, options?: TOptions, ): Promise => @@ -159,7 +209,7 @@ export const buildContentRepeatableOperations = ({ ), delete: async ( - itemId: number, + itemId: ContentId, childId: number, options?: TOptions, ): Promise => @@ -171,13 +221,13 @@ export const buildContentRepeatableOperations = ({ ), list: async ( - itemId: number, + itemId: ContentId, options?: unknown, ): Promise[]> => asRows(await api.read(itemId, field, options)), reorder: async ( - itemId: number, + itemId: ContentId, childIds: readonly number[], options?: TOptions, ): Promise => @@ -188,7 +238,7 @@ export const buildContentRepeatableOperations = ({ const rows = asRows(current); assertContentPermutation({ contentTypeId, - current: rows.map(row => Number(row.id)), + current: asChildIds(rows.map(row => row.id)), field, next: childIds, noun: "entry", @@ -202,13 +252,13 @@ export const buildContentRepeatableOperations = ({ ), set: async ( - itemId: number, + itemId: ContentId, rows: readonly Record[], options?: TOptions, ): Promise => await api.write(itemId, field, rows, options), update: async ( - itemId: number, + itemId: ContentId, childId: number, values: Record, options?: TOptions, diff --git a/packages/vitnode/src/content/server/column-builders.ts b/packages/vitnode/src/content/server/column-builders.ts index a62dfec1ee..421104e2a4 100644 --- a/packages/vitnode/src/content/server/column-builders.ts +++ b/packages/vitnode/src/content/server/column-builders.ts @@ -1,6 +1,7 @@ import type { AnyPgColumn, AnyPgColumnBuilder } from "drizzle-orm/pg-core"; import { + bigint, boolean, doublePrecision, integer, @@ -8,11 +9,13 @@ import { serial, text, timestamp, + uuid, varchar, } from "drizzle-orm/pg-core"; import type { AnyBlockInstance } from "../../blocks/types"; import type { RichTextDocument } from "../rich-text/document"; +import type { ContentIdStrategy } from "../ids"; import type { ContentFieldDescriptor } from "../types"; import { @@ -26,8 +29,47 @@ import { ContentEngineError } from "../errors"; export type ColumnReferenceThunk = () => AnyPgColumn; -export const buildSystemColumns = (): Record => ({ - id: serial().primaryKey(), +/** + * A content table's primary key, per id strategy. + * + * - `serial`: exactly the column every content type has always had. + * - `uuid`: `gen_random_uuid()`, so an insert that names no id still gets one. + * - `bigint`: an identity column in Drizzle's `string` mode - `bigserial` has no + * string mode, and a JavaScript number would round anything above 2^53. + * `BY DEFAULT` rather than `ALWAYS`, so an import can keep its own ids and a + * sequence can be moved with `setval`. + */ +export const buildIdPrimaryKey = ( + strategy: ContentIdStrategy, +): AnyPgColumnBuilder => { + if (strategy === "uuid") return uuid().primaryKey().defaultRandom(); + if (strategy === "bigint") { + return bigint({ mode: "string" }) + .primaryKey() + .generatedByDefaultAsIdentity(); + } + + return serial().primaryKey(); +}; + +/** + * A foreign key column of the type a `strategy` primary key has, nullable until + * the caller says otherwise. + * Every column that points at a content row - a to-one relation, a junction's + * two sides, a translation's or a child row's owner - is built here, so the + * types cannot disagree with the key they reference. + */ +export const buildIdForeignKey = (strategy: ContentIdStrategy) => + strategy === "uuid" + ? uuid() + : strategy === "bigint" + ? bigint({ mode: "string" }) + : integer(); + +export const buildSystemColumns = ( + strategy: ContentIdStrategy = "serial", +): Record => ({ + id: buildIdPrimaryKey(strategy), createdAt: timestamp().notNull().defaultNow(), updatedAt: timestamp() .notNull() @@ -74,14 +116,17 @@ export const buildEditorialColumns = (): Record< export const buildTranslationSystemColumns = ({ itemReference, + itemStrategy = "serial", languageReference, onItemDelete = "cascade", }: { itemReference: ColumnReferenceThunk; + /** The owning content type's id strategy, which `itemId` follows. */ + itemStrategy?: ContentIdStrategy; languageReference: ColumnReferenceThunk; onItemDelete?: "cascade"; }): Record => ({ - itemId: integer() + itemId: buildIdForeignKey(itemStrategy) .notNull() // Cascade: a record's translations are part of the record, so removing it // takes them with it in one statement - there is no loop over locales @@ -130,11 +175,14 @@ export const buildContentColumn = ({ fieldValue, name, reference, + referenceStrategy = "serial", }: { contentTypeId: string; fieldValue: ContentFieldDescriptor; name: string; reference?: ColumnReferenceThunk; + /** For a to-one `relation`: the target's id strategy, which the column follows. */ + referenceStrategy?: ContentIdStrategy; }): AnyPgColumnBuilder => { const { nullable } = fieldValue; @@ -216,10 +264,16 @@ export const buildContentColumn = ({ ); } - const column = integer().references(reference, { + // The column has the type of the key it points at: `integer` for a user + // or a serial target, `uuid` or `bigint` for the other strategies. + const column = ( + fieldValue.kind === "relation" + ? buildIdForeignKey(referenceStrategy) + : integer() + ).references(reference, { onDelete: fieldValue.onDelete, - // Identifiers are `serial`, so an update is only ever a repair; cascade - // keeps children pointing at the right row either way. + // Identifiers are generated and never rewritten, so an update is only + // ever a repair; cascade keeps children pointing at the right row. onUpdate: "cascade", }); diff --git a/packages/vitnode/src/content/server/delivery-alternates.ts b/packages/vitnode/src/content/server/delivery-alternates.ts index 1ef23ce54a..88f9d565e2 100644 --- a/packages/vitnode/src/content/server/delivery-alternates.ts +++ b/packages/vitnode/src/content/server/delivery-alternates.ts @@ -9,6 +9,7 @@ import type { Context } from "hono"; import { and, asc, eq, inArray } from "drizzle-orm"; import type { ContentDeliveryAlternate } from "../delivery"; +import type { ContentId } from "../ids"; import type { AnyContentTypeDefinition } from "../types"; import type { ContentModel } from "./model"; import type { ContentDatabase } from "./service"; @@ -17,6 +18,7 @@ import { contentDeliveryInternalPath, contentDeliveryPublicUrl, } from "../delivery"; +import { contentIdKey, parseContentId } from "../ids"; import { listContentLanguages } from "./language-resolver"; import { contentLocaleRouting } from "./locale-routing"; import { @@ -33,7 +35,7 @@ export const readDeliveryAlternates = async < model, }: { c: Context; - itemId: number; + itemId: ContentId; model: ContentModel; }): Promise => { const batched = await readDeliveryAlternatesMany({ @@ -42,7 +44,7 @@ export const readDeliveryAlternates = async < model, }); - return batched.get(itemId) ?? []; + return batched.get(contentIdKey(itemId)) ?? []; }; export const readDeliveryAlternatesMany = async < @@ -55,11 +57,13 @@ export const readDeliveryAlternatesMany = async < }: { c: Context; database?: ContentDatabase; - itemIds: readonly number[]; + itemIds: readonly ContentId[]; model: ContentModel; -}): Promise> => { +}): Promise> => { const { columns, definition, translationColumns, translationTable } = model; - const grouped = new Map(); + // Keyed by `contentIdKey`, so a caller's id and the driver's meet whatever the + // strategy - and a bigint is never rounded on the way. + const grouped = new Map(); if ( itemIds.length === 0 || @@ -122,7 +126,7 @@ export const readDeliveryAlternatesMany = async < // The selected keys come back as `unknown` through the generic column map, so // each one is narrowed here rather than asserted - the same treatment the // sitemap query gives its own projection. - const itemId = typeof row.itemId === "number" ? row.itemId : null; + const itemId = parseContentId(definition.idStrategy, row.itemId); const languageId = typeof row.languageId === "number" ? row.languageId : null; if (itemId === null || languageId === null) continue; @@ -140,14 +144,15 @@ export const readDeliveryAlternatesMany = async < }); if (url === null || internalPath === null) continue; - const entries = grouped.get(itemId) ?? []; + const itemKey = contentIdKey(itemId); + const entries = grouped.get(itemKey) ?? []; entries.push({ internalPath, locale: language.locale, ...(url.origin === undefined ? {} : { origin: url.origin }), path: url.pathname, }); - grouped.set(itemId, entries); + grouped.set(itemKey, entries); } for (const entries of grouped.values()) { diff --git a/packages/vitnode/src/content/server/delivery-effects.ts b/packages/vitnode/src/content/server/delivery-effects.ts index 72ee5ba857..3f3ab75deb 100644 --- a/packages/vitnode/src/content/server/delivery-effects.ts +++ b/packages/vitnode/src/content/server/delivery-effects.ts @@ -2,6 +2,7 @@ import type { Context } from "hono"; import type { EventEmitResult } from "../../api/models/events"; import type { ContentDeliveryInvalidation } from "../cache"; +import type { ContentId } from "../ids"; import type { AnyContentTypeDefinition } from "../types"; import type { ContentDeliveryOutcome } from "./delivery-writes"; @@ -23,7 +24,7 @@ export const contentDeliveryEffects = async ( const announce = async ( action: "delivery_redirect_created" | "delivery_slug_changed", payload: Record, - { itemId, locale }: { itemId: number; locale: null | string }, + { itemId, locale }: { itemId: ContentId; locale: null | string }, ): Promise => { const event = await emitContentEvent( c, diff --git a/packages/vitnode/src/content/server/delivery-routes.ts b/packages/vitnode/src/content/server/delivery-routes.ts index 3f6fe4e75c..f5b9fe40c8 100644 --- a/packages/vitnode/src/content/server/delivery-routes.ts +++ b/packages/vitnode/src/content/server/delivery-routes.ts @@ -16,8 +16,10 @@ import { CONTENT_SITEMAP_MAX_URLS, } from "../const"; import { ContentDeliveryNotEnabled } from "../errors"; +import { contentIdParamSchema, contentIdSchema } from "../ids"; import { resolveContentPublicLocale } from "../locale"; import { listContentLanguages } from "./language-resolver"; +import { contentIdentifier } from "./route-helpers"; export const buildContentDeliveryRoutes = < TDefinition extends AnyContentTypeDefinition, @@ -38,6 +40,13 @@ export const buildContentDeliveryRoutes = < return build(c, { pluginId }); }; + // A `serial` content type keeps exactly the schemas it always had, so its + // OpenAPI document does not move; `uuid` and `bigint` carry canonical strings. + const serial = definition.idStrategy === "serial"; + const itemIdSchema = serial + ? z.number().int() + : contentIdSchema(definition.idStrategy); + const localeQuery = localized ? { // Loose on purpose, like `publicParams.slug`: an unknown locale and a @@ -86,7 +95,7 @@ export const buildContentDeliveryRoutes = < isFallback: z.boolean(), // Nullable: delivery metadata is read off the public projection, so a content // type whose allowlist withholds `id` reports none rather than inventing one. - itemId: z.number().int().nullable(), + itemId: itemIdSchema.nullable(), locale: z.string().nullable(), openGraph: zodSeo.nullable(), requestedLocale: z.string().nullable(), @@ -106,7 +115,7 @@ export const buildContentDeliveryRoutes = < const zodSitemapEntry = z.object({ changeFrequency: z.string().nullable(), - itemId: z.number().int(), + itemId: itemIdSchema, lastModified: z.string(), locale: z.string().nullable(), origin: z.string().optional(), @@ -116,7 +125,9 @@ export const buildContentDeliveryRoutes = < const sitemapQuery = z.object({ ...localeQuery, - cursor: z.coerce.number().int().positive().optional(), + cursor: serial + ? z.coerce.number().int().positive().optional() + : contentIdParamSchema(definition.idStrategy).optional(), limit: z.coerce .number() .int() @@ -165,7 +176,11 @@ export const buildContentDeliveryRoutes = < path: "/delivery/item/{id}", description: `Delivery metadata for one published ${name}`, request: { - params: z.object({ id: z.coerce.number().int().positive() }), + params: z.object({ + id: serial + ? z.coerce.number().int().positive() + : contentIdSchema(definition.idStrategy), + }), ...(localized ? { query: z.object(localeQuery) } : {}), }, responses: { @@ -180,7 +195,7 @@ export const buildContentDeliveryRoutes = < const resolved = await localeFor(c); if (!resolved) throw notFound(name); - const id = Number(c.req.param("id")); + const id = contentIdentifier(c, definition); const metadata = await service(c).findById(id, { locale: resolved.locale, }); @@ -203,7 +218,7 @@ export const buildContentDeliveryRoutes = < "application/json": { schema: z.object({ entries: z.array(zodSitemapEntry), - nextCursor: z.number().int().nullable(), + nextCursor: itemIdSchema.nullable(), }), }, }, diff --git a/packages/vitnode/src/content/server/delivery-service.ts b/packages/vitnode/src/content/server/delivery-service.ts index fcede23b75..11cdefa3b1 100644 --- a/packages/vitnode/src/content/server/delivery-service.ts +++ b/packages/vitnode/src/content/server/delivery-service.ts @@ -6,6 +6,7 @@ import type { ContentDeliveryRobots, ContentDeliverySeo, } from "../delivery"; +import type { ContentId, ContentIdOf } from "../ids"; import type { ContentSitemapEntry } from "../sitemap"; import type { AnyContentTypeDefinition } from "../types"; import type { ContentDeliverySitemapPage } from "./delivery-sitemap"; @@ -24,6 +25,7 @@ import { parseContentDeliveryPath, } from "../delivery"; import { ContentDeliveryNotEnabled } from "../errors"; +import { parseContentId } from "../ids"; import { contentLocalesMatch, normalizeContentLocale } from "../locale"; import { contentPublicHref } from "../public-url"; import { readDeliveryAlternates } from "./delivery-alternates"; @@ -44,7 +46,7 @@ export interface ContentDeliveryMetadata { /** Whether `locale` differs from `requestedLocale`. */ isFallback: boolean; - itemId: null | number; + itemId: ContentId | null; /** The language this response is actually in. */ locale: null | string; /** `null` unless `delivery.seo.openGraph` is configured. */ @@ -71,7 +73,7 @@ export interface ContentDeliveryReadOptions { export interface ContentDeliverySitemapArgs { /** The last `itemId` of the previous page. Keyset, never an offset. */ - cursor?: number; + cursor?: ContentId; /** Defaults to `CONTENT_SITEMAP_DEFAULT_PAGE_SIZE`, capped at the protocol's. */ limit?: number; /** Required for a localized content type; each language is its own sitemap. */ @@ -79,15 +81,15 @@ export interface ContentDeliverySitemapArgs { } export interface ContentDeliveryService { - alternates: (itemId: number) => Promise; + alternates: (itemId: ContentId) => Promise; /** Delivery metadata by identifier, honouring the content type's fallback. */ findById: ( - itemId: number, + itemId: ContentId, options?: ContentDeliveryReadOptions, ) => Promise; history: ( - itemId: number, + itemId: ContentId, options?: { locale?: string }, ) => Promise; @@ -174,7 +176,7 @@ export const createContentDeliveryService = < origin, requestedLocale, }: { - itemId: null | number; + itemId: ContentId | null; origin?: string; requestedLocale: null | string; }, @@ -221,18 +223,21 @@ export const createContentDeliveryService = < }; const readAlternates = async ( - itemId: number, + itemId: ContentId, ): Promise => localized ? await readDeliveryAlternates({ c, itemId, model }) : []; const strictCanonical = async ( - itemId: number, + itemId: ContentId, locale: null | string, host: null | string | undefined, ): Promise => { - const row = await buildPublic(c).findById(itemId, { - locale: locale ?? undefined, - }); + const row = await buildPublic(c).findById( + itemId as ContentIdOf, + { + locale: locale ?? undefined, + }, + ); if (!row) return null; const values = row as Record; @@ -279,7 +284,7 @@ export const createContentDeliveryService = < }); if (row) { const values = row as Record; - const itemId = typeof values.id === "number" ? values.id : null; + const itemId = parseContentId(definition.idStrategy, values.id); if (itemId !== null && slugOf(definition, values) !== slug) { const destination = await strictCanonical( @@ -372,7 +377,10 @@ export const createContentDeliveryService = < alternates: async itemId => await readAlternates(itemId), findById: async (itemId, { locale, origin } = {}) => { - const row = await buildPublic(c).findById(itemId, { locale }); + const row = await buildPublic(c).findById( + itemId as ContentIdOf, + { locale }, + ); if (!row) return null; return await metadataFor(row, { diff --git a/packages/vitnode/src/content/server/delivery-sitemap.ts b/packages/vitnode/src/content/server/delivery-sitemap.ts index fe68c41e44..a567e4f4eb 100644 --- a/packages/vitnode/src/content/server/delivery-sitemap.ts +++ b/packages/vitnode/src/content/server/delivery-sitemap.ts @@ -4,6 +4,7 @@ import type { Context } from "hono"; import { and, asc, eq, gt, sql } from "drizzle-orm"; +import type { ContentId } from "../ids"; import type { ContentLocaleRouting } from "../public-url"; import type { ContentSitemapEntry } from "../sitemap"; import type { AnyContentTypeDefinition } from "../types"; @@ -16,6 +17,7 @@ import { } from "../const"; import { contentDeliveryPublicUrl } from "../delivery"; import { ContentDeliveryNotEnabled } from "../errors"; +import { parseContentId } from "../ids"; import { splitContentFieldPath } from "../paths"; import { findContentLanguage } from "./language-resolver"; import { contentLocaleRouting } from "./locale-routing"; @@ -27,8 +29,13 @@ import { export interface ContentDeliverySitemapPage { entries: ContentSitemapEntry[]; - /** Pass back as `cursor`. `null` when this was the last page. */ - nextCursor: null | number; + /** + * Pass back as `cursor`. `null` when this was the last page. The last listed + * record's id: pages are keyed on the primary key, which orders numerically for + * `serial` and `bigint` and lexically for `uuid` - a uuid sitemap is complete + * and stable, just not in creation order. + */ + nextCursor: ContentId | null; } const noIndexColumn = ( @@ -72,6 +79,10 @@ export const readContentDeliverySitemapPage = async < // a caller error. if (!sitemap.enabled) return { entries: [], nextCursor: null }; + const cursor = + args.cursor === undefined + ? null + : parseContentId(definition.idStrategy, args.cursor); const limit = Math.max( 1, Math.min( @@ -90,7 +101,10 @@ export const readContentDeliverySitemapPage = async < const conditions: (SQL | undefined)[] = [ publishedCondition(base), - args.cursor === undefined ? undefined : gt(columns.id, args.cursor), + // Re-read under the strategy rather than trusted: a cursor the strategy + // cannot hold is treated as "from the start" by the route's schema already, + // and never reaches Postgres as a cast error. + cursor === null ? undefined : gt(columns.id, cursor), // `IS DISTINCT FROM TRUE`, which is the only spelling that agrees with the // metadata. `contentDeliveryRobots` reads `value !== true`, so a `noIndex` of // `null` means `index: true` - and the sitemap has to list exactly what claims @@ -127,7 +141,7 @@ export const readContentDeliverySitemapPage = async < locale: null, routing: contentLocaleRouting(c), rows: rows.map(row => ({ - itemId: row.itemId as number, + itemId: row.itemId as ContentId, lastModified: row.lastModified as Date, slug: row.slug, })), @@ -200,7 +214,7 @@ export const readContentDeliverySitemapPage = async < locale: language.locale, routing: contentLocaleRouting(c), rows: rows.map(row => ({ - itemId: row.itemId as number, + itemId: row.itemId as ContentId, // `greatest()` comes back as a string on some drivers, so it is normalized // here rather than trusted - a sitemap `lastmod` of "Invalid Date" is a // document a crawler rejects. @@ -230,7 +244,7 @@ const page = ({ limit: number; locale: null | string; routing: ContentLocaleRouting; - rows: readonly { itemId: number; lastModified: Date; slug: unknown }[]; + rows: readonly { itemId: ContentId; lastModified: Date; slug: unknown }[]; }): ContentDeliverySitemapPage => { const visible = rows.slice(0, limit); const { sitemap } = definition.delivery; diff --git a/packages/vitnode/src/content/server/delivery-writes.ts b/packages/vitnode/src/content/server/delivery-writes.ts index 2e7fafb05b..2c000b7daa 100644 --- a/packages/vitnode/src/content/server/delivery-writes.ts +++ b/packages/vitnode/src/content/server/delivery-writes.ts @@ -1,6 +1,7 @@ import type { Context } from "hono"; import type { ContentSitemapChange } from "../cache"; +import type { ContentId } from "../ids"; import type { ContentLocaleRouting } from "../public-url"; import type { AnyContentTypeDefinition } from "../types"; import type { ContentDatabase } from "./service"; @@ -12,7 +13,7 @@ import { createContentSlugHistoryModel } from "./slug-history-model"; export interface ContentDeliveryOutcome { /** The path the record answers to after this mutation. */ canonicalPath: null | string; - itemId: number; + itemId: ContentId; /** `null` when the slug is shared - see `core_content_slug_history`. */ locale: null | string; /** The path it answered to before, when the mutation moved it. */ @@ -31,7 +32,7 @@ export interface ContentDeliveryOutcome { export interface ContentDeliveryTransition { /** Whether the record is publicly reachable *after* the mutation. */ isPublic: boolean; - itemId: number; + itemId: ContentId; /** `null` when the slug is shared. */ languageId: null | number; /** The canonical locale code, or `null` when the slug is shared. */ diff --git a/packages/vitnode/src/content/server/duplicate-effects.ts b/packages/vitnode/src/content/server/duplicate-effects.ts index 949de7b109..ce8cd7ae30 100644 --- a/packages/vitnode/src/content/server/duplicate-effects.ts +++ b/packages/vitnode/src/content/server/duplicate-effects.ts @@ -8,6 +8,7 @@ import type { } from "./duplicate"; import type { AnyContentModel } from "./model"; +import { requireContentId } from "../ids"; import { contentEditorialEffects } from "./editorial-effects"; import { reportContentEventFailures } from "./effects-log"; import { emitContentEvent } from "./emit"; @@ -55,7 +56,11 @@ export const contentDuplicateEffects = async ( const row = input.kind === "editorial" ? input.outcome.row : input.result.row; const sourceId = input.kind === "editorial" ? input.outcome.sourceId : input.result.sourceId; - const contentId = (row as { id: number }).id; + const contentId = requireContentId( + definition.idStrategy, + (row as { id: unknown }).id, + definition.id, + ); if (input.kind === "editorial") { await contentEditorialEffects(c, definition, input.outcome, { diff --git a/packages/vitnode/src/content/server/duplicate.ts b/packages/vitnode/src/content/server/duplicate.ts index 992d1bf9ae..795ce68691 100644 --- a/packages/vitnode/src/content/server/duplicate.ts +++ b/packages/vitnode/src/content/server/duplicate.ts @@ -39,6 +39,7 @@ import { ContentEngineError, ContentInputError, } from "../errors"; +import { parseContentId } from "../ids"; import { partitionContentFields } from "../localization"; import { contentColumnsToValues, @@ -67,6 +68,12 @@ export interface ContentDuplicateOptions { tx?: ContentDatabase; } +/** The two halves of a duplicate request body: shared and per-locale overrides. */ +export type ContentDuplicateOverrides = Pick< + ContentDuplicateOptions, + "overrides" | "translations" +>; + export interface ContentEditorialDuplicateOptions< TDefinition, > extends ContentDuplicateOptions { @@ -115,9 +122,13 @@ export interface ContentEditorialDuplicationMethods { } /** The part of the translation model a duplicate reads through. */ +/** + * The part of the translation model a duplicate reads through, erased over the + * id strategy: the duplicate hands it back the source's own canonical id. + */ export interface ContentDuplicateTranslationSource { findManyRowsForItem: ( - itemId: number, + itemId: ContentId, options?: { tx?: ContentDatabase }, ) => Promise<{ languageId: number; locale: string; values: object }[]>; resolveLanguage: ( @@ -150,9 +161,9 @@ export interface ContentDuplicateWriter { values: Record, tx: ContentDatabase, ) => Promise; - idOf: (base: TBase) => number; + idOf: (base: TBase) => ContentId; translation: ( - itemId: number, + itemId: ContentId, locale: string, values: Record, tx: ContentDatabase, @@ -355,9 +366,10 @@ export const runContentDuplicate = async ( ); } - // Serial is the only strategy with tables today; `parseContentId` is the - // boundary that will widen this. - const id = Number(sourceId); + // Canonicalised once, so `"7"` and `7` - or a bigint and its own digits - read + // the same row. Unparseable cannot name a record, which is a miss, not a 500. + const id = parseContentId(definition.idStrategy, sourceId); + if (id === null) return null; const { localizedFields, sharedFields, collectionFields } = partitionContentFields(definition.fields); const writableFields: ContentFieldMap = { diff --git a/packages/vitnode/src/content/server/editorial-effects.ts b/packages/vitnode/src/content/server/editorial-effects.ts index f8f42f855d..ba0b79f26b 100644 --- a/packages/vitnode/src/content/server/editorial-effects.ts +++ b/packages/vitnode/src/content/server/editorial-effects.ts @@ -2,12 +2,14 @@ import type { Context } from "hono"; import type { EventEmitResult } from "../../api/models/events"; import type { ContentEventAction } from "../events"; +import type { ContentId } from "../ids"; import type { AnyContentTypeDefinition } from "../types"; import type { ContentDeliveryEffectsResult } from "./delivery-effects"; import type { ContentEditorialOutcome } from "./editorial-service"; import type { AnyContentModel } from "./model"; import type { ContentSearchSyncOutcome } from "./search-sync"; +import { parseContentId } from "../ids"; import { contentDeliveryEffects } from "./delivery-effects"; import { reportContentEventFailures } from "./effects-log"; import { emitContentEvent } from "./emit"; @@ -151,7 +153,7 @@ export const contentEditorialEffects = async ( action: EVENT_ACTION[outcome.operation], contentTypeId: definition.id, event, - itemId: idOf(outcome.row), + itemId: idOf(definition, outcome.row) ?? 0, }); // After the ordinary event, never instead of it: a URL moving and a field moving @@ -176,11 +178,7 @@ export const contentEditorialEffects = async ( // A shared field moved, so **every** locale's document is rewritten // - and each rewrite has to carry the shared collections too, or a // title edit would drop the FAQ out of every language at once. - advanced: await contentSearchAdvancedValues( - c, - model, - idOf(outcome.row), - ), + advanced: await searchAdvancedOf(c, model, definition, outcome.row), // The base row not moving is not the same as nothing moving. A // record whose languages were still drafts publishes them without // touching a base column, and those documents are exactly the ones @@ -203,7 +201,7 @@ export const contentEditorialEffects = async ( // and only the collections it names. A content type that indexes none - // which is every Stage 1-5 one - pays for nothing here. advanced: model - ? await contentSearchAdvancedValues(c, model, idOf(outcome.row)) + ? await searchAdvancedOf(c, model, definition, outcome.row) : undefined, changed: outcome.changed, changedFields: outcome.changedFields, @@ -214,11 +212,29 @@ export const contentEditorialEffects = async ( }; }; -/** The record's own identifier, off a row whose type is still open. */ -const idOf = (row: object): number => { - const id = (row as { id?: unknown }).id; +/** + * The record's own identifier, off a row whose type is still open - read under + * the content type's strategy, so a `uuid` or `bigint` record is never mistaken + * for one without an id. `null` only for a row that carries none at all. + */ +const idOf = ( + definition: AnyContentTypeDefinition, + row: object, +): ContentId | null => + parseContentId(definition.idStrategy, (row as { id?: unknown }).id); + +/** {@link idOf} for the collections a search document needs, or nothing. */ +const searchAdvancedOf = async ( + c: Context, + model: AnyContentModel, + definition: AnyContentTypeDefinition, + row: object, +): Promise | undefined> => { + const id = idOf(definition, row); - return typeof id === "number" ? id : 0; + return id === null + ? undefined + : await contentSearchAdvancedValues(c, model, id); }; const warnMissingModel = async ( diff --git a/packages/vitnode/src/content/server/editorial-service.ts b/packages/vitnode/src/content/server/editorial-service.ts index 95bd467552..7d0282b268 100644 --- a/packages/vitnode/src/content/server/editorial-service.ts +++ b/packages/vitnode/src/content/server/editorial-service.ts @@ -9,15 +9,17 @@ import type { Context } from "hono"; import { and, eq, ne, sql } from "drizzle-orm"; -import type { ContentId } from "../ids"; +import type { ContentId, ContentIdOf } from "../ids"; import type { ContentActor, ContentRevisionOperation } from "../revisions"; import type { ContentSchemas } from "../schemas"; import type { AnyContentTypeDefinition, ContentChangedPath, ContentCreateInput, + ContentFieldsOf, ContentInnerFieldsOf, ContentLocalizedValues, + ContentReferenceIdOf, ContentRelationCollectionName, ContentRepeatableFieldName, ContentRepeatableInputRow, @@ -28,7 +30,10 @@ import type { } from "../types"; import type { ContentAdvancedStore } from "./advanced-store"; import type { ContentDeliveryOutcome } from "./delivery-writes"; -import type { ContentEditorialDuplicationMethods } from "./duplicate"; +import type { + ContentDuplicateTranslationSource, + ContentEditorialDuplicationMethods, +} from "./duplicate"; import type { ContentRevisionsModel } from "./revisions-model"; import type { ContentSchedulesModel } from "./schedules-model"; import type { ContentDatabase } from "./service"; @@ -53,6 +58,7 @@ import { ContentRevisionNotRestorable, ContentVersionConflict, } from "../errors"; +import { contentRelationStrategy, requireContentId } from "../ids"; import { partitionContentFields } from "../localization"; import { contentColumnsToValues, contentStorageColumns } from "../paths"; import { @@ -154,18 +160,22 @@ export interface ContentEditorialServiceBase { ) => Promise>; delete: ( - id: number, + id: ContentIdOf, options: ContentEditorialWriteOptions, ) => Promise | null>; publish: ( - id: number, + id: ContentIdOf, options: ContentEditorialPublicationOptions, ) => Promise | null>; - relations: Record< - ContentRelationCollectionName, - ContentEditorialRelationMethods - >; + relations: { + [ + K in ContentRelationCollectionName + ]: ContentEditorialRelationMethods< + TDefinition, + ContentReferenceIdOf[K]> + >; + }; /** The editorial repeatable operations, each carrying its own child shape. */ repeatable: { [ @@ -173,7 +183,7 @@ export interface ContentEditorialServiceBase { ]: ContentEditorialRepeatableMethods; }; restore: ( - id: number, + id: ContentIdOf, revisionId: number, options: ContentEditorialWriteOptions, ) => Promise | null>; @@ -182,37 +192,44 @@ export interface ContentEditorialServiceBase { schedules: ContentSchedulesModel | undefined; unpublish: ( - id: number, + id: ContentIdOf, options: ContentEditorialPublicationOptions, ) => Promise | null>; update: ( - id: number, + id: ContentIdOf, values: ContentUpdateInput, options: ContentEditorialWriteOptions, ) => Promise | null>; } -export interface ContentEditorialRelationMethods { +/** The editorial collection API of one to-many reference. See `ContentRelationMethods`. */ +export interface ContentEditorialRelationMethods< + TDefinition, + TRelatedId = ContentId, +> { add: ( - itemId: number, - relatedItemId: number, + itemId: ContentIdOf, + relatedItemId: TRelatedId, options: ContentEditorialWriteOptions, ) => Promise | null>; /** The current targets, in stored order. Reads take no version. */ - get: (itemId: number, options?: ContentEditorialOptions) => Promise; + get: ( + itemId: ContentIdOf, + options?: ContentEditorialOptions, + ) => Promise; remove: ( - itemId: number, - relatedItemId: number, + itemId: ContentIdOf, + relatedItemId: TRelatedId, options: ContentEditorialWriteOptions, ) => Promise | null>; reorder: ( - itemId: number, - relatedItemIds: readonly number[], + itemId: ContentIdOf, + relatedItemIds: readonly TRelatedId[], options: ContentEditorialWriteOptions, ) => Promise | null>; set: ( - itemId: number, - relatedItemIds: readonly number[], + itemId: ContentIdOf, + relatedItemIds: readonly TRelatedId[], options: ContentEditorialWriteOptions, ) => Promise | null>; } @@ -220,35 +237,35 @@ export interface ContentEditorialRelationMethods { /** The editorial repeatable operations, typed from the field's own leaves. */ export interface ContentEditorialRepeatableMethods { create: ( - itemId: number, + itemId: ContentIdOf, values: ContentValuesOf>, options: ContentEditorialWriteOptions, ) => Promise | null>; delete: ( - itemId: number, + itemId: ContentIdOf, childId: number, options: ContentEditorialWriteOptions, ) => Promise | null>; list: ( - itemId: number, + itemId: ContentIdOf, options?: ContentEditorialOptions, ) => Promise< ContentRepeatableRow>[] >; reorder: ( - itemId: number, + itemId: ContentIdOf, childIds: readonly number[], options: ContentEditorialWriteOptions, ) => Promise | null>; set: ( - itemId: number, + itemId: ContentIdOf, rows: readonly ContentRepeatableInputRow< ContentInnerFieldsOf >[], options: ContentEditorialWriteOptions, ) => Promise | null>; update: ( - itemId: number, + itemId: ContentIdOf, childId: number, values: Partial>>, options: ContentEditorialWriteOptions, @@ -275,7 +292,7 @@ export const createContentEditorialService = < cascadeTranslations?: | ((options: { actor: ContentActor; - itemId: number; + itemId: ContentId; operation: "publish" | "unpublish"; tx: ContentDatabase; }) => Promise) @@ -424,7 +441,7 @@ export const createContentEditorialService = < }: { after: null | Record; before: null | Record; - itemId: number; + itemId: ContentId; }, ): Promise => { if (!deliveryEnabled) return undefined; @@ -470,7 +487,7 @@ export const createContentEditorialService = < }; const readOne = async ( - id: number, + id: ContentId, database: ContentDatabase, ): Promise> => { const [row] = await database @@ -510,7 +527,11 @@ export const createContentEditorialService = < version: number; }, ): Promise => { - const itemId = typeof row.id === "number" ? row.id : 0; + const itemId = requireContentId( + definition.idStrategy, + row.id, + contentTypeId, + ); // Read **after** the write and **inside** the transaction, so the snapshot // is the post-mutation state rather than the state the caller sent. A delete // is the one case where the rows are already gone, and the caller passes the @@ -554,7 +575,7 @@ export const createContentEditorialService = < */ const lockRow = async ( tx: ContentDatabase, - id: number, + id: ContentId, { force = false }: { force?: boolean } = {}, ): Promise => { if (!force && !store?.enabled) return true; @@ -580,7 +601,7 @@ export const createContentEditorialService = < */ const guardedWrite = async ( tx: ContentDatabase, - id: number, + id: ContentId, expectedVersion: number, values: Record, ): Promise> => { @@ -627,7 +648,7 @@ export const createContentEditorialService = < */ const cascade = async ( tx: ContentDatabase, - itemId: number, + itemId: ContentId, operation: "publish" | "unpublish", actor: ContentActor, ): Promise => { @@ -648,7 +669,7 @@ export const createContentEditorialService = < * `cascadeTranslations`. */ const transition = async ( - id: number, + id: ContentId, options: ContentEditorialPublicationOptions, operation: "publish" | "unpublish", values: Record, @@ -762,7 +783,7 @@ export const createContentEditorialService = < * a repeat a harmless no-op - with `expectedVersion` checked on top when given. */ const visibilityTransition = async ( - id: number, + id: ContentId, options: ContentEditorialVisibilityOptions, action: ContentVisibilityAction, ): Promise | null> => @@ -847,9 +868,9 @@ export const createContentEditorialService = < const hideableMethods: ContentEditorialVisibilityMethods = { hide: async (id, options) => - await visibilityTransition(id as number, options, "hide"), + await visibilityTransition(id, options, "hide"), unhide: async (id, options) => - await visibilityTransition(id as number, options, "unhide"), + await visibilityTransition(id, options, "unhide"), }; /** @@ -874,7 +895,7 @@ export const createContentEditorialService = < }; const runCollection = async ( - itemId: number, + itemId: ContentId, field: string, compute: (current: unknown[]) => unknown[], options: Partial | undefined, @@ -889,7 +910,7 @@ export const createContentEditorialService = < const next = compute(Array.isArray(value) ? value : []); return await editorial.update( - itemId, + itemId as ContentIdOf, schemas.update.parse({ [field]: next, }), @@ -899,7 +920,7 @@ export const createContentEditorialService = < }; const collectionApi = { - read: async (itemId: number, field: string, options: unknown) => { + read: async (itemId: ContentId, field: string, options: unknown) => { const loaded = await store?.load( itemId, (options as ContentEditorialOptions | undefined)?.tx ?? c.get("db"), @@ -911,7 +932,7 @@ export const createContentEditorialService = < }, run: runCollection, write: async ( - itemId: number, + itemId: ContentId, field: string, next: readonly unknown[], options: Partial | undefined, @@ -923,7 +944,7 @@ export const createContentEditorialService = < const write = assertWriteOptions(field, options); return await editorial.update( - itemId, + itemId as ContentIdOf, { [field]: [...next] } as ContentUpdateInput, write, ); @@ -945,10 +966,15 @@ export const createContentEditorialService = < > = {}; for (const field of relations) { + const fieldValue = definition.fields[field]; mutableRelations[field] = buildContentRelationOperations({ api: collectionApi, contentTypeId, field, + strategy: + fieldValue.kind === "relation" + ? contentRelationStrategy(definition, fieldValue) + : "serial", }); } for (const field of repeatables) { @@ -956,7 +982,7 @@ export const createContentEditorialService = < api: collectionApi, contentTypeId, field, - }) as ContentEditorialRepeatableMethods; + }) as unknown as ContentEditorialRepeatableMethods; } const editorial: ContentEditorialServiceBase = { @@ -977,9 +1003,12 @@ export const createContentEditorialService = < // In the same transaction as the row: a create that committed its // categories and rolled back its article would leave junction rows // pointing at nothing. - if (store?.enabled && typeof row.id === "number") { - await store.write(tx, row.id, parsed); - } + const itemId = requireContentId( + definition.idStrategy, + row.id, + contentTypeId, + ); + if (store?.enabled) await store.write(tx, itemId, parsed); const version = versionOf(row); const revisionId = await capture(tx, { @@ -998,7 +1027,7 @@ export const createContentEditorialService = < const delivery = await applyDelivery(tx, { after: row, before: null, - itemId: typeof row.id === "number" ? row.id : 0, + itemId, }); return { @@ -1245,7 +1274,8 @@ export const createContentEditorialService = < }; }), - relations: mutableRelations, + relations: + mutableRelations as unknown as ContentEditorialServiceBase["relations"], repeatable: mutableRepeatables as ContentEditorialServiceBase["repeatable"], @@ -1377,7 +1407,15 @@ export const createContentEditorialService = < definition, pluginId, table, - translation: translations, + // Erased to `ContentId`: the duplicate only ever hands back the + // canonical id it read off this content type's row. + translation: translations as + | undefined + | { + columns: Record; + model: () => ContentDuplicateTranslationSource; + table: PgTable; + }, }, sourceId, options, @@ -1387,7 +1425,12 @@ export const createContentEditorialService = < actor: options.actor, tx, }), - idOf: outcome => (outcome.row as { id: number }).id, + idOf: outcome => + requireContentId( + definition.idStrategy, + (outcome.row as { id: unknown }).id, + contentTypeId, + ), translation: async (itemId, locale, values, tx) => { if (!translations) { throw new ContentEngineError( @@ -1399,7 +1442,7 @@ export const createContentEditorialService = < return await translations .editorial() .create( - itemId, + itemId as ContentIdOf, locale, values as ContentLocalizedValues, { actor: options.actor, tx }, diff --git a/packages/vitnode/src/content/server/effects-log.ts b/packages/vitnode/src/content/server/effects-log.ts index a577e4e3ae..bb13b42d2e 100644 --- a/packages/vitnode/src/content/server/effects-log.ts +++ b/packages/vitnode/src/content/server/effects-log.ts @@ -1,6 +1,7 @@ import type { Context } from "hono"; import type { EventEmitResult } from "../../api/models/events"; +import type { ContentId } from "../ids"; export const CONTENT_EFFECTS_LOG_PREFIX = "[content-effects]"; @@ -16,7 +17,7 @@ export const reportContentEventFailures = async ( action: string; contentTypeId: string; event: EventEmitResult | null; - itemId: number; + itemId: ContentId; /** Present only for a translation mutation. */ locale?: string; }, diff --git a/packages/vitnode/src/content/server/emit.ts b/packages/vitnode/src/content/server/emit.ts index 3ea9680dd5..ee90269fd0 100644 --- a/packages/vitnode/src/content/server/emit.ts +++ b/packages/vitnode/src/content/server/emit.ts @@ -29,20 +29,20 @@ import type { AnyContentTypeDefinition } from "../types"; import { contentEventName } from "../events"; type ContentPayload = - | ContentCreatedPayload - | ContentDeletedPayload + | ContentCreatedPayload + | ContentDeletedPayload | ContentDuplicableCreatedPayload | ContentDuplicatedPayload | ContentHiddenPayload - | ContentPublishedPayload - | ContentTranslationCreatedPayload - | ContentTranslationDeletedPayload - | ContentTranslationPublishedPayload + | ContentPublishedPayload + | ContentTranslationCreatedPayload + | ContentTranslationDeletedPayload + | ContentTranslationPublishedPayload | ContentTranslationRestoredPayload - | ContentTranslationUnpublishedPayload + | ContentTranslationUnpublishedPayload | ContentTranslationUpdatedPayload | ContentUnhiddenPayload - | ContentUnpublishedPayload + | ContentUnpublishedPayload | ContentUpdatedPayload; interface ContentEventEmitter { diff --git a/packages/vitnode/src/content/server/http-errors.ts b/packages/vitnode/src/content/server/http-errors.ts index ca36bce457..8f37a5acff 100644 --- a/packages/vitnode/src/content/server/http-errors.ts +++ b/packages/vitnode/src/content/server/http-errors.ts @@ -7,6 +7,7 @@ import type { ContentDuplicateRejection, ContentUnprocessable, } from "../conflicts"; +import type { ContentId } from "../ids"; import type { ContentScheduleCode } from "../schedules"; import { PG_ERROR_CODES, pgErrorCode } from "../../lib/api/pg-error"; @@ -82,7 +83,7 @@ export const rethrowAsHttpError = ( }: { action: "create" | "delete" | "update"; contentTypeId?: string; - itemId?: number; + itemId?: ContentId; structured?: boolean; }, ): never => { @@ -202,7 +203,7 @@ export const rethrowAsHttpError = ( export interface ContentHttpErrorOptions { contentTypeId?: string; - itemId?: number; + itemId?: ContentId; /** Answer 409 and 422 with a JSON body. Editorial content types only. */ structured?: boolean; } diff --git a/packages/vitnode/src/content/server/list-references.ts b/packages/vitnode/src/content/server/list-references.ts index a8c09f34fd..bf9cd79aab 100644 --- a/packages/vitnode/src/content/server/list-references.ts +++ b/packages/vitnode/src/content/server/list-references.ts @@ -3,28 +3,39 @@ import type { Context } from "hono"; import { z } from "@hono/zod-openapi"; import { eq, inArray } from "drizzle-orm"; +import type { ContentId } from "../ids"; import type { AnyContentTypeDefinition } from "../types"; import type { AnyContentModel } from "./model"; import { core_roles } from "../../database/roles"; import { core_users } from "../../database/users"; +import { contentIdKey, contentRelationStrategy } from "../ids"; import { isContentReferenceCollection } from "../paths"; import { contentDefinitionOf } from "./model"; -export const zodContentReferenceListItem = z.object({ - color: z.string().optional(), - label: z.string(), - role: z - .object({ - color: z.string().nullable(), - prefix: z.string().nullable(), - }) - .optional(), - value: z.number(), -}); +const zodReferenceListItemOf = (value: TValue) => + z.object({ + color: z.string().optional(), + label: z.string(), + role: z + .object({ + color: z.string().nullable(), + prefix: z.string().nullable(), + }) + .optional(), + value, + }); + +/** One label in a list cell. `value` is the target's id - a number for a user or a serial target. */ +export const zodContentReferenceListItem = zodReferenceListItemOf(z.number()); + +/** {@link zodContentReferenceListItem} for a list whose targets include a `uuid` or `bigint` content type. */ +export const zodContentReferenceListItemAnyId = zodReferenceListItemOf( + z.union([z.number(), z.string()]), +); export type ContentReferenceListItem = z.infer< - typeof zodContentReferenceListItem + typeof zodContentReferenceListItemAnyId >; export const contentReferenceListColumns = ( @@ -40,14 +51,34 @@ export const contentReferenceListColumns = ( ); }); -const idsOf = (value: unknown): number[] => +/** + * Whether every column `contentReferenceListColumns` names points at numeric + * ids - users and serial targets - so the list response keeps its old schema. + */ +export const contentReferenceListIdsAreNumeric = ( + definition: AnyContentTypeDefinition, +): boolean => + contentReferenceListColumns(definition).every(name => { + const fieldValue = definition.fields[name]; + + return ( + fieldValue.kind !== "relation" || + contentRelationStrategy(definition, fieldValue) === "serial" + ); + }); + +/** The loaded ids of one collection, as the store returned them. */ +const idsOf = (value: unknown): ContentId[] => Array.isArray(value) - ? value.filter((id): id is number => typeof id === "number") + ? value.filter( + (id): id is ContentId => + typeof id === "number" || typeof id === "string", + ) : []; const userItems = async ( c: Context, - ids: readonly number[], + ids: readonly ContentId[], ): Promise => { if (ids.length === 0) return []; @@ -61,7 +92,12 @@ const userItems = async ( }) .from(core_users) .leftJoin(core_roles, eq(core_roles.id, core_users.roleId)) - .where(inArray(core_users.id, [...ids])); + .where( + inArray( + core_users.id, + ids.filter((id): id is number => typeof id === "number"), + ), + ); return rows.map(({ color, label, prefix, value }) => ({ label, @@ -74,7 +110,7 @@ const relationItems = async ( c: Context, model: AnyContentModel, field: string, - ids: readonly number[], + ids: readonly ContentId[], ): Promise => { const options = await model .service(c) @@ -87,7 +123,7 @@ const relationItems = async ( })); }; -export const withContentReferenceLists = async ( +export const withContentReferenceLists = async ( c: Context, model: AnyContentModel, rows: readonly TRow[], @@ -114,7 +150,10 @@ export const withContentReferenceLists = async ( ? await userItems(c, ids) : await relationItems(c, model, field, ids); - return [field, new Map(items.map(item => [item.value, item]))] as const; + return [ + field, + new Map(items.map(item => [contentIdKey(item.value), item])), + ] as const; }), ); @@ -124,7 +163,7 @@ export const withContentReferenceLists = async ( itemsByField.map(([field, items]) => [ field, idsOf(values.get(row.id)?.[field]).flatMap(id => { - const item = items.get(id); + const item = items.get(contentIdKey(id)); return item ? [item] : []; }), diff --git a/packages/vitnode/src/content/server/localized-admin-routes.ts b/packages/vitnode/src/content/server/localized-admin-routes.ts index 1db22a00dd..ea35fa61bf 100644 --- a/packages/vitnode/src/content/server/localized-admin-routes.ts +++ b/packages/vitnode/src/content/server/localized-admin-routes.ts @@ -3,6 +3,7 @@ import type { Context } from "hono"; import { z } from "@hono/zod-openapi"; import { HTTPException } from "hono/http-exception"; +import type { ContentIdOf } from "../ids"; import type { AnyContentTypeDefinition, ContentLocalizedUpdateValues, @@ -24,6 +25,7 @@ import { } from "../conflicts"; import { CONTENT_LOCALE_MAX_LENGTH, CONTENT_PERMISSIONS } from "../const"; import { zodContentFileReferenceRejection } from "../files"; +import { requireContentId } from "../ids"; import { resolveContentActor } from "./actor"; import { contentEditorialEffects } from "./editorial-effects"; import { emitContentEvent } from "./emit"; @@ -31,7 +33,7 @@ import { contentFileFields } from "./files"; import { withHttpErrors } from "./http-errors"; import { commitContentDrafts } from "./live-store"; import { - identifier, + contentIdentifier, jsonBody, jsonResponse, plainOutcome, @@ -155,7 +157,7 @@ export const buildContentLocalizedAdminRoutes = < */ const writeTranslation = async ( c: Context, - itemId: number, + itemId: ContentIdOf, entry: TranslationEntry, tx: ContentDatabase, ): Promise | null> => { @@ -258,7 +260,11 @@ export const buildContentLocalizedAdminRoutes = < return; } - const id = (row as { id: number }).id; + const id = requireContentId( + definition.idStrategy, + (row as { id: unknown }).id, + definition.id, + ); await emitContentEvent( c, @@ -336,7 +342,11 @@ export const buildContentLocalizedAdminRoutes = < for (const entry of ordered) { const translated = await writeTranslation( c, - (row as { id: number }).id, + requireContentId( + definition.idStrategy, + (row as { id: unknown }).id, + definition.id, + ) as ContentIdOf, entry, tx, ); @@ -383,7 +393,7 @@ export const buildContentLocalizedAdminRoutes = < }, handler: async c => { const savedFrom = new Date(); - const id = identifier(c); + const id = contentIdentifier(c, definition); const body = await readJson(c, updateBody); const entries = body.translations as TranslationEntry[]; const shared = body.values; diff --git a/packages/vitnode/src/content/server/model.ts b/packages/vitnode/src/content/server/model.ts index ae32482a4a..2e27af0af6 100644 --- a/packages/vitnode/src/content/server/model.ts +++ b/packages/vitnode/src/content/server/model.ts @@ -1,6 +1,7 @@ import type { PgColumn } from "drizzle-orm/pg-core"; import type { Context } from "hono"; +import type { ContentIdOf } from "../ids"; import type { ContentSchemas, ContentTranslationSchemas } from "../schemas"; import type { AnyContentTypeDefinition, @@ -252,7 +253,10 @@ export const createContentModel = < // Read in the caller's transaction, so a language created a // moment ago in the same transaction is moved too. - const rows = await translations.findManyForItem(itemId, { + // Handed over as the content type's own id by the + // editorial service, which read it off the base row. + const recordId = itemId as ContentIdOf; + const rows = await translations.findManyForItem(recordId, { tx, }); @@ -275,7 +279,7 @@ export const createContentModel = < } const outcome = await editorial[operation]( - itemId, + recordId, row.locale, { actor, tx }, ); diff --git a/packages/vitnode/src/content/server/preview-target.ts b/packages/vitnode/src/content/server/preview-target.ts index e548095a3c..139845a26d 100644 --- a/packages/vitnode/src/content/server/preview-target.ts +++ b/packages/vitnode/src/content/server/preview-target.ts @@ -1,5 +1,6 @@ import type { Context } from "hono"; +import type { ContentIdOf } from "../ids"; import type { AnyContentTypeDefinition } from "../types"; import type { ContentModel } from "./model"; @@ -30,7 +31,7 @@ export const resolveContentPreviewTarget = async < >( c: Context, model: ContentModel, - { id, row }: { id: number; row: unknown }, + { id, row }: { id: ContentIdOf; row: unknown }, ): Promise => { const { definition } = model; const slugField = previewSlugField(definition); @@ -69,7 +70,7 @@ export const resolveContentTranslationPreviewSlug = async < >( c: Context, model: ContentModel, - { id, values }: { id: number; values: unknown }, + { id, values }: { id: ContentIdOf; values: unknown }, ): Promise => { const { definition } = model; const slugField = previewSlugField(definition); diff --git a/packages/vitnode/src/content/server/preview-token.ts b/packages/vitnode/src/content/server/preview-token.ts index a727067c32..dd3a9f1bcd 100644 --- a/packages/vitnode/src/content/server/preview-token.ts +++ b/packages/vitnode/src/content/server/preview-token.ts @@ -1,19 +1,30 @@ import { z } from "zod"; +import type { ContentId } from "../ids"; import type { AnyContentTypeDefinition } from "../types"; import { signPayload, verifySignedPayload } from "../../lib/api/signed-token"; import { + CONTENT_ID_KEY_LENGTH, CONTENT_LOCALE_MAX_LENGTH, CONTENT_PREVIEW_TOKEN_VERSION, } from "../const"; +import { parseContentId } from "../ids"; export const zodContentPreviewTokenPayload = z.object({ /** Rejects a token minted for anything else that ever shares this secret. */ aud: z.literal("content-preview"), /** Epoch **seconds**, not milliseconds. */ exp: z.number().int().positive(), - i: z.number().int().positive(), + /** + * The record: a number for `serial` - exactly what every token minted before + * strategies existed carries - or the canonical string for `uuid` and + * `bigint`. Re-read under the content type's strategy on verification. + */ + i: z.union([ + z.number().int().positive(), + z.string().min(1).max(CONTENT_ID_KEY_LENGTH), + ]), l: z.string().min(1).max(CONTENT_LOCALE_MAX_LENGTH).optional(), /** `core_languages.id`, so the reader needs no second lookup. */ @@ -49,7 +60,7 @@ export const createContentPreviewToken = ({ version, }: { definition: AnyContentTypeDefinition; - itemId: number; + itemId: ContentId; /** Required with `locale`: `core_languages.id` for that locale. */ languageId?: number; @@ -121,5 +132,10 @@ export const verifyContentPreviewToken = ({ const minted = payload.l?.trim().toLowerCase(); if (wanted !== minted) return null; - return payload; + // Canonical under this content type's strategy, or no record at all: the id + // is used in a query next, and must never reach Postgres as a cast error. + const itemId = parseContentId(definition.idStrategy, payload.i); + if (itemId === null) return null; + + return { ...payload, i: itemId }; }; diff --git a/packages/vitnode/src/content/server/public-locales.ts b/packages/vitnode/src/content/server/public-locales.ts index 61b8e62da5..0e8a44fb38 100644 --- a/packages/vitnode/src/content/server/public-locales.ts +++ b/packages/vitnode/src/content/server/public-locales.ts @@ -4,6 +4,7 @@ import type { Context } from "hono"; import { eq } from "drizzle-orm"; import type { ContentPublicLocaleState } from "../cache"; +import type { ContentId } from "../ids"; import type { AnyContentTypeDefinition } from "../types"; import type { ContentModel } from "./model"; @@ -36,7 +37,7 @@ export const contentPublicLocaleStates = async < >( c: Context, model: ContentModel, - itemId: number, + itemId: ContentId, { row }: { row?: Record } = {}, ): Promise => { const { definition } = model; diff --git a/packages/vitnode/src/content/server/public-routes.ts b/packages/vitnode/src/content/server/public-routes.ts index 9fc6166193..751eb0fc33 100644 --- a/packages/vitnode/src/content/server/public-routes.ts +++ b/packages/vitnode/src/content/server/public-routes.ts @@ -10,6 +10,7 @@ import { z } from "@hono/zod-openapi"; import { and, eq } from "drizzle-orm"; import { HTTPException } from "hono/http-exception"; +import type { ContentIdOf } from "../ids"; import type { AnyContentTypeDefinition, ContentPublicFilterInput, @@ -239,8 +240,10 @@ export const buildContentPublicRoutes = < const build = model.translationEditorialService; if (!build) return null; + // Canonical under this content type's strategy: the token was verified + // against it before this ran. const revision = await build(c, { pluginId }).findRevision( - payload.i, + payload.i as ContentIdOf, language.locale, payload.tr, ); diff --git a/packages/vitnode/src/content/server/public-row-hydration.ts b/packages/vitnode/src/content/server/public-row-hydration.ts index bc06986591..e1ab459c0c 100644 --- a/packages/vitnode/src/content/server/public-row-hydration.ts +++ b/packages/vitnode/src/content/server/public-row-hydration.ts @@ -1,8 +1,10 @@ import type { Context } from "hono"; +import type { ContentId } from "../ids"; import type { AnyContentTypeDefinition } from "../types"; import type { ContentAdvancedStore } from "./advanced-store"; +import { isContentId } from "../ids"; import { splitContentFieldPath } from "../paths"; import { resolveContentPublicRowFiles } from "./files"; @@ -72,9 +74,12 @@ export const createContentPublicRowHydrator = const nested = rows.map(nestContentPublicRow); if (nested.length === 0) return nested; + // Read under the content type's own strategy: a `uuid` or `bigint` row's + // id is a string, and skipping it would serve the record without its + // collections. const ids = nested .map(row => row.id) - .filter((id): id is number => typeof id === "number"); + .filter((id): id is ContentId => isContentId(definition.idStrategy, id)); const loaded = publicCollections.length === 0 ? undefined @@ -85,7 +90,9 @@ export const createContentPublicRowHydrator = ? nested : nested.map(row => ({ ...row, - ...(typeof row.id === "number" ? loaded.get(row.id) : undefined), + ...(isContentId(definition.idStrategy, row.id) + ? loaded.get(row.id) + : undefined), })); // The identifier is replaced by the descriptor here rather than in the diff --git a/packages/vitnode/src/content/server/public-service.ts b/packages/vitnode/src/content/server/public-service.ts index 6be5add16f..46c98ac0d9 100644 --- a/packages/vitnode/src/content/server/public-service.ts +++ b/packages/vitnode/src/content/server/public-service.ts @@ -9,6 +9,7 @@ import type { Context } from "hono"; import { and, eq } from "drizzle-orm"; import type { PaginationCursorColumn } from "../../api/lib/with-pagination"; +import type { ContentIdOf } from "../ids"; import type { AnyContentTypeDefinition, ContentPublicFilterInput, @@ -25,6 +26,7 @@ import { CONTENT_PUBLIC_MAX_PAGE_SIZE, } from "../const"; import { ContentEngineError } from "../errors"; +import { contentRelationStrategy, parseContentId } from "../ids"; import { isContentReferenceCollection, splitContentFieldPath } from "../paths"; import { publicOrderableColumns } from "../registry"; import { groupPublicLeafPaths } from "../schemas"; @@ -69,7 +71,7 @@ export interface ContentPublicFindManyArgs< export interface ContentPublicService { /** `null` unless the row exists *and* is published. */ findById: ( - id: number, + id: ContentIdOf, options?: ContentPublicReadOptions, ) => Promise | null>; @@ -115,6 +117,20 @@ export const createContentPublicProjector = < isContentReferenceCollection(definition.fields[name]), ), ); + // The strategy each to-one target is keyed by, so a `uuid` or `bigint` + // reference projects as `{ id }` rather than vanishing as a non-number. + const toOneStrategies = new Map( + [...exposedToOne].map(name => { + const fieldValue = definition.fields[name]; + + return [ + name, + fieldValue.kind === "relation" + ? contentRelationStrategy(definition, fieldValue) + : "serial", + ] as const; + }), + ); // Leaf-level privacy, resolved once: `seo` carries only the leaves the // allowlist named, whatever else the group declares. const containers = [...groupPublicLeafPaths(exposed)].map( @@ -140,8 +156,11 @@ export const createContentPublicProjector = < continue; } - const id = row[name]; - projected[name] = typeof id === "number" ? { id } : null; + const id = parseContentId( + toOneStrategies.get(name) ?? "serial", + row[name], + ); + projected[name] = id === null ? null : { id }; } for (const { leaves, owner, repeatable } of containers) { diff --git a/packages/vitnode/src/content/server/query.ts b/packages/vitnode/src/content/server/query.ts index e9337adbfd..4f356e8c76 100644 --- a/packages/vitnode/src/content/server/query.ts +++ b/packages/vitnode/src/content/server/query.ts @@ -170,7 +170,12 @@ export const buildFilterCondition = ({ // table rather than an equality against something that does not exist. if (fieldValue && isContentReferenceCollection(fieldValue)) { const filter = raw as ContentRelationFilter; - if (typeof filter?.contains !== "number") { + // A number or a string: which one is right is the target's strategy, and + // the membership condition checks it against that. + if ( + typeof filter?.contains !== "number" && + typeof filter?.contains !== "string" + ) { throw new ContentEngineError( `Filter "${name}" is a to-many relation, which takes \`{ contains: }\` rather than a value.`, { contentTypeId }, diff --git a/packages/vitnode/src/content/server/revision-snapshot.ts b/packages/vitnode/src/content/server/revision-snapshot.ts index 1fd4ae64a6..363c9badf7 100644 --- a/packages/vitnode/src/content/server/revision-snapshot.ts +++ b/packages/vitnode/src/content/server/revision-snapshot.ts @@ -7,6 +7,11 @@ import type { import type { AnyContentTypeDefinition, ContentFieldMap } from "../types"; import { CONTENT_REVISION_SNAPSHOT_VERSION } from "../const"; +import { + contentIdsOf, + contentRelationStrategy, + requireContentId, +} from "../ids"; import { partitionContentFields } from "../localization"; import { contentInnerFields, @@ -41,6 +46,7 @@ const toSnapshotValue = (value: unknown): ContentSnapshotScalar => { }; const toFieldSnapshot = ( + definition: AnyContentTypeDefinition, name: string, fieldValue: ContentFieldMap[string], values: Record, @@ -67,8 +73,15 @@ const toFieldSnapshot = ( if (isContentReferenceCollection(fieldValue)) { const value = values[name]; + // Under the target's strategy: a `uuid` or `bigint` target's ids are + // strings, and dropping them would make a restore empty the collection. return Array.isArray(value) - ? value.map(id => Number(id)).filter(id => Number.isInteger(id)) + ? contentIdsOf( + fieldValue.kind === "relation" + ? contentRelationStrategy(definition, fieldValue) + : "serial", + value, + ) : []; } @@ -113,14 +126,14 @@ export const contentRevisionSnapshot = ( ...collectionFields, }; for (const [name, fieldValue] of Object.entries(snapshotFields)) { - fields[name] = toFieldSnapshot(name, fieldValue, values); + fields[name] = toFieldSnapshot(definition, name, fieldValue, values); } const snapshot: ContentRevisionSnapshot = { contentTypeId: definition.id, createdAt: toIso(values.createdAt), fields, - id: typeof values.id === "number" ? values.id : 0, + id: requireContentId(definition.idStrategy, values.id, definition.id), schemaVersion: CONTENT_REVISION_SNAPSHOT_VERSION, updatedAt: toIso(values.updatedAt), version: typeof values.version === "number" ? values.version : 1, @@ -235,14 +248,18 @@ export const contentTranslationRevisionSnapshot = ( // coercion - which returns `null` for an object, and would silently record // every localized group as absent. for (const [name, fieldValue] of Object.entries(localizedFields)) { - fields[name] = toFieldSnapshot(name, fieldValue, values); + fields[name] = toFieldSnapshot(definition, name, fieldValue, values); } const snapshot: ContentTranslationRevisionSnapshot = { contentTypeId: definition.id, createdAt: toIso(values.createdAt), fields, - itemId: typeof values.itemId === "number" ? values.itemId : 0, + itemId: requireContentId( + definition.idStrategy, + values.itemId, + definition.id, + ), languageId, locale, schemaVersion: CONTENT_REVISION_SNAPSHOT_VERSION, diff --git a/packages/vitnode/src/content/server/revisions-model.test.ts b/packages/vitnode/src/content/server/revisions-model.test.ts index 8c9cf19810..b98f23565f 100644 --- a/packages/vitnode/src/content/server/revisions-model.test.ts +++ b/packages/vitnode/src/content/server/revisions-model.test.ts @@ -91,8 +91,9 @@ const cursorOf = (condition: unknown): null | number => { .map(chunk => (chunk as null | { value?: unknown })?.value) .filter((value): value is number => typeof value === "number"); - // The scope predicate contributes the item id; the cursor is the last one. - return params.length > 1 ? (params.at(-1) ?? null) : null; + // The scope predicate binds the item id as its storage key - a string - so + // the only numeric parameter is the version cursor, when there is one. + return params.at(-1) ?? null; }; describe("revision pagination", () => { diff --git a/packages/vitnode/src/content/server/revisions-model.ts b/packages/vitnode/src/content/server/revisions-model.ts index fee9c10dfc..4bf98dbde3 100644 --- a/packages/vitnode/src/content/server/revisions-model.ts +++ b/packages/vitnode/src/content/server/revisions-model.ts @@ -2,6 +2,7 @@ import type { Context } from "hono"; import { and, desc, eq, isNull, lt, lte, notInArray, sql } from "drizzle-orm"; +import type { ContentId } from "../ids"; import type { ContentActor, ContentAnyRevisionSnapshot, @@ -19,6 +20,7 @@ import { } from "../../database/content"; import { core_roles } from "../../database/roles"; import { core_users } from "../../database/users"; +import { contentIdKey } from "../ids"; export interface ContentRevisionCaptureInput< TSnapshot = ContentRevisionSnapshot, @@ -27,7 +29,7 @@ export interface ContentRevisionCaptureInput< changedFields: readonly string[]; fileIds?: readonly number[]; - itemId: number; + itemId: ContentId; operation: ContentRevisionOperation; restoredFromRevisionId?: number; snapshot: TSnapshot; @@ -41,18 +43,18 @@ export interface ContentRevisionsModel { input: ContentRevisionCaptureInput, ) => Promise; findById: ( - itemId: number, + itemId: ContentId, revisionId: number, tx?: ContentDatabase, ) => Promise | null>; latest: ( - itemId: number, + itemId: ContentId, tx?: ContentDatabase, ) => Promise; /** Newest first. Metadata only - a snapshot is loaded on demand. */ list: ( - itemId: number, + itemId: ContentId, args?: { cursor?: number; limit?: number }, ) => Promise; } @@ -86,11 +88,11 @@ export const createContentRevisionsModel = < const retention = definition.editorial.revisions.retention; /** The scope predicate. Not optional anywhere, which is the point. */ - const scope = (itemId: number) => + const scope = (itemId: ContentId) => and( eq(core_content_revisions.pluginId, pluginId), eq(core_content_revisions.contentTypeId, contentTypeId), - eq(core_content_revisions.itemId, itemId), + eq(core_content_revisions.itemId, contentIdKey(itemId)), // `IS NULL` rather than `= NULL`: the shared scope is the absence of a // language, and an equality against `null` matches nothing in SQL. languageId === null @@ -121,7 +123,7 @@ export const createContentRevisionsModel = < actorUserId: input.actor.userId, changedFields: [...input.changedFields], contentTypeId, - itemId: input.itemId, + itemId: contentIdKey(input.itemId), languageId, operation: input.operation, pluginId, diff --git a/packages/vitnode/src/content/server/route-helpers.ts b/packages/vitnode/src/content/server/route-helpers.ts index 1bacde6260..bcda6cd99e 100644 --- a/packages/vitnode/src/content/server/route-helpers.ts +++ b/packages/vitnode/src/content/server/route-helpers.ts @@ -3,22 +3,55 @@ import type { Context } from "hono"; import { HTTPException } from "hono/http-exception"; +import type { ContentIdOf, ContentIdStrategy } from "../ids"; import type { ContentLocalizedFieldName, ContentTranslationRow, } from "../types"; import type { ContentTranslationEditorialOutcome } from "./translation-editorial-service"; -/** The positive `{id}` in the path, or a 400 before any handler runs. */ -export const identifier = (c: Context): number => { - const value = Number(c.req.param("id")); - if (!Number.isInteger(value) || value <= 0) { +import { CONTENT_SERIAL_MAX, parseContentId } from "../ids"; + +/** + * The positive `{id}` in the path, or a 400 before any handler runs. Read the + * way `Number` reads it, as a `serial` route always has - and capped at the + * largest `integer`, so an id Postgres could never hold is a 400 rather than an + * out-of-range error from the driver. + */ +export const identifier = (c: Context, param = "id"): number => { + const value = Number(c.req.param(param)); + if (!Number.isInteger(value) || value <= 0 || value > CONTENT_SERIAL_MAX) { throw new HTTPException(400, { message: "Invalid identifier." }); } return value; }; +/** + * {@link identifier} for one content type, under its id strategy. A `serial` + * content type reads exactly as before; a `uuid` or `bigint` one accepts only + * the canonical spelling - lowercase hyphenated hex, or decimal digits within + * range - so a malformed id is a 400 here and never a Postgres cast error. + */ +export const contentIdentifier = < + TDefinition extends { idStrategy: ContentIdStrategy }, +>( + c: Context, + definition: TDefinition, + param = "id", +): ContentIdOf => { + if (definition.idStrategy === "serial") { + return identifier(c, param) as ContentIdOf; + } + + const value = parseContentId(definition.idStrategy, c.req.param(param)); + if (value === null) { + throw new HTTPException(400, { message: "Invalid identifier." }); + } + + return value as ContentIdOf; +}; + /** * The validated payload, re-read through the very schema that produced it. * diff --git a/packages/vitnode/src/content/server/routes.ts b/packages/vitnode/src/content/server/routes.ts index 783ab78955..c2ed40a8d0 100644 --- a/packages/vitnode/src/content/server/routes.ts +++ b/packages/vitnode/src/content/server/routes.ts @@ -4,7 +4,7 @@ import { z } from "@hono/zod-openapi"; import { HTTPException } from "hono/http-exception"; import type { StorageFileUploadResult } from "../../api/models/storage"; -import type { ContentIdOf } from "../ids"; +import type { ContentId, ContentIdOf, ContentIdStrategy } from "../ids"; import type { AnyContentTypeDefinition, ContentFilterInput, @@ -12,6 +12,7 @@ import type { ContentReferenceFieldName, } from "../types"; import type { + ContentDuplicateOverrides, ContentDuplicationMethods, ContentEditorialDuplicationMethods, } from "./duplicate"; @@ -53,7 +54,13 @@ import { zodContentFileFieldValue, zodContentFileReferenceRejection, } from "../files"; -import { contentIdSchema } from "../ids"; +import { + contentIdKey, + contentIdSchema, + contentRelationStrategy, + parseContentId, + requireContentId, +} from "../ids"; import { partitionContentFields } from "../localization"; import { orderableColumns } from "../registry"; import { resolveContentActor } from "./actor"; @@ -70,8 +77,10 @@ import { withHttpErrors } from "./http-errors"; import { findContentLanguage } from "./language-resolver"; import { contentReferenceListColumns, + contentReferenceListIdsAreNumeric, withContentReferenceLists, zodContentReferenceListItem, + zodContentReferenceListItemAnyId, } from "./list-references"; import { buildContentLiveRoutes } from "./live-routes"; import { commitContentDrafts } from "./live-store"; @@ -79,6 +88,7 @@ import { resetContentLiveRecord } from "./live/hooks"; import { contentLocaleRouting } from "./locale-routing"; import { buildContentLocalizedAdminRoutes } from "./localized-admin-routes"; import { resolveContentLocalizedValues } from "./localized-display"; +import { contentDefinitionOf } from "./model"; import { assertContentPreviewIsServable, contentPreviewSecret, @@ -89,7 +99,7 @@ import { createContentPreviewToken } from "./preview-token"; import { publicationMethods } from "./publication"; import { CONTENT_REVISIONS_MAX_PAGE_SIZE } from "./revisions-model"; import { - identifier, + contentIdentifier, jsonBody, jsonResponse, readJson, @@ -102,21 +112,61 @@ import { contentVisibilityEffects } from "./visibility-effects"; const zodLabels = z.record(z.string(), z.string().nullable()); -const zodOptions = z.object({ - items: z.array( - z.object({ - avatarColor: z.string().optional(), - /** - * Present when the target declares `admin.colorField` - a blog category - * is a colour as much as it is a word, and the picker draws it. - */ - color: z.string().optional(), - label: z.string(), - nameCode: z.string().optional(), - value: z.number(), - }), - ), -}); +/** + * The picker options response. `value` is a number for every target a + * `serial` content type can point at today; a content type with a `uuid` or + * `bigint` target answers strings for those, so its schema says either. + */ +const zodOptionsOf = (value: z.ZodType) => + z.object({ + items: z.array( + z.object({ + avatarColor: z.string().optional(), + /** + * Present when the target declares `admin.colorField` - a blog category + * is a colour as much as it is a word, and the picker draws it. + */ + color: z.string().optional(), + label: z.string(), + nameCode: z.string().optional(), + value, + }), + ), + }); + +const zodOptions = zodOptionsOf(z.number()); +const zodOptionsAnyId = zodOptionsOf(z.union([z.number(), z.string()])); + +/** + * The id strategy one reference field's values follow: its target's for a + * relation, `serial` for a user. + */ +const referenceStrategy = ( + definition: AnyContentTypeDefinition, + field: string, +): ContentIdStrategy => { + const fieldValue = definition.fields[field]; + + return fieldValue?.kind === "relation" + ? contentRelationStrategy(definition, fieldValue) + : "serial"; +}; + +/** + * One `?ids=` entry, read under the target's strategy. A serial target keeps + * reading it the way `Number` always has; the others accept only the canonical + * spelling. + */ +const parseOptionId = ( + strategy: ContentIdStrategy, + value: string, +): ContentId | null => { + if (strategy !== "serial") return parseContentId(strategy, value); + + const number = Number(value); + + return Number.isInteger(number) ? number : null; +}; const notFound = (definition: AnyContentTypeDefinition): HTTPException => new HTTPException(404, { @@ -135,11 +185,14 @@ const advancedForSearch = async ( model: AnyContentModel, row: object, ): Promise | undefined> => { - const id = (row as { id?: unknown }).id; - - return typeof id === "number" - ? await contentSearchAdvancedValues(c, model, id) - : undefined; + const id = parseContentId( + contentDefinitionOf(model).idStrategy, + (row as { id?: unknown }).id, + ); + + return id === null + ? undefined + : await contentSearchAdvancedValues(c, model, id); }; /** @@ -242,7 +295,11 @@ export const buildContentRoutes = < ? { references: z.record( z.string(), - z.array(zodContentReferenceListItem), + z.array( + contentReferenceListIdsAreNumeric(definition) + ? zodContentReferenceListItem + : zodContentReferenceListItemAnyId, + ), ), } : {}), @@ -467,7 +524,7 @@ export const buildContentRoutes = < */ const withRowTranslations = async ( c: Context, - data: { edges: { id: number }[]; pageInfo: unknown }, + data: { edges: { id: ContentId }[]; pageInfo: unknown }, locale: string | undefined, ) => { const build = model.translationService; @@ -493,18 +550,19 @@ export const buildContentRoutes = < )?.[0] ?? null; const rows = await translations.findManyRowsForItems( - data.edges.map(row => row.id), + data.edges.map(row => row.id as ContentIdOf), ); - const byItem = new Map(); + // Keyed by storage key, so a number and a string spelling of one id meet. + const byItem = new Map(); for (const row of rows) { - const itemId = row.itemId as number; - byItem.set(itemId, [...(byItem.get(itemId) ?? []), row]); + const itemKey = contentIdKey(row.itemId); + byItem.set(itemKey, [...(byItem.get(itemKey) ?? []), row]); } return { ...data, edges: data.edges.map(row => { - const all = byItem.get(row.id) ?? []; + const all = byItem.get(contentIdKey(row.id)) ?? []; const localizedValues = resolveContentLocalizedValues({ defaultLocale: definition.localization.defaultLocale, fields: localizedFieldNames, @@ -571,7 +629,14 @@ export const buildContentRoutes = < }), }, responses: { - 200: jsonResponse(zodOptions, `Up to ${CONTENT_OPTIONS_LIMIT} options`), + 200: jsonResponse( + referenceFieldNames.every( + field => referenceStrategy(definition, field) === "serial", + ) + ? zodOptions + : zodOptionsAnyId, + `Up to ${CONTENT_OPTIONS_LIMIT} options`, + ), 400: { description: "Not a relation or user field" }, }, }, @@ -584,16 +649,17 @@ export const buildContentRoutes = < } const raw = c.req.query("ids"); - // Anything that is not a whole number is dropped rather than refused: the - // list is a lookup key, and one malformed entry should cost that entry - // rather than the whole form's labels. + // Anything that is not an identifier of the target is dropped rather than + // refused: the list is a lookup key, and one malformed entry should cost + // that entry rather than the whole form's labels. + const strategy = referenceStrategy(definition, field); const ids = raw === undefined ? undefined : raw .split(",") - .map(value => Number(value.trim())) - .filter(value => Number.isInteger(value)); + .map(value => parseOptionId(strategy, value.trim())) + .filter((value): value is ContentId => value !== null); const items = await model .service(c) @@ -816,7 +882,7 @@ export const buildContentRoutes = < }, }, handler: async c => { - const id = identifier(c); + const id = contentIdentifier(c, definition); const service = model.service(c); const row = await service.findRowById(id); if (!row) throw notFound(definition); @@ -982,13 +1048,14 @@ export const buildContentRoutes = < }); } - const id = identifier(c); + const id = contentIdentifier(c, definition); const body = c.req.header("content-type")?.includes("json") ? await readJson(c, duplicateBody) : {}; - const overrides = body.overrides; - const translations = (body as { translations?: Record }) - .translations; + // Validated by `duplicateBody`, which is built from these very schemas - + // the types are re-attached here, as `buildContentSchemas` does for its own. + const { overrides, translations } = + body as ContentDuplicateOverrides; const actor = resolveContentActor(c); if (editorial) { @@ -997,10 +1064,10 @@ export const buildContentRoutes = < async () => await duplicationMethods< ContentEditorialDuplicationMethods - >(definition, editorialService(c)).duplicate(id as never, { + >(definition, editorialService(c)).duplicate(id, { actor, - overrides: overrides as never, - translations: translations as never, + overrides, + translations, }), { contentTypeId: definition.id, itemId: id, structured: true }, ); @@ -1015,7 +1082,11 @@ export const buildContentRoutes = < return c.json( { - id: (outcome.row as { id: number }).id, + id: requireContentId( + definition.idStrategy, + outcome.row.id, + definition.id, + ), locales: outcome.translations.map(entry => entry.locale), row: outcome.row, skippedLocales: outcome.skippedLocales, @@ -1031,10 +1102,7 @@ export const buildContentRoutes = < await duplicationMethods>( definition, model.service(c), - ).duplicate(id as never, { - overrides: overrides as never, - translations: translations as never, - }), + ).duplicate(id, { overrides, translations }), { contentTypeId: definition.id, itemId: id, structured: true }, ); if (!result) throw notFound(definition); @@ -1048,7 +1116,11 @@ export const buildContentRoutes = < return c.json( { - id: (result.row as { id: number }).id, + id: requireContentId( + definition.idStrategy, + result.row.id, + definition.id, + ), locales: result.translations.map(entry => entry.locale), row: result.row, skippedLocales: result.skippedLocales, @@ -1088,7 +1160,7 @@ export const buildContentRoutes = < }, handler: async c => { const savedFrom = new Date(); - const id = identifier(c); + const id = contentIdentifier(c, definition); const { expectedVersion, values } = await readJson( c, schemas.updateEnvelope, @@ -1142,7 +1214,7 @@ export const buildContentRoutes = < const values = await readJson(c, schemas.update); const result = await withHttpErrors("update", async () => - model.service(c).update(identifier(c), values), + model.service(c).update(contentIdentifier(c, definition), values), ); if (!result) throw notFound(definition); @@ -1196,7 +1268,7 @@ export const buildContentRoutes = < }, }, handler: async c => { - const id = identifier(c); + const id = contentIdentifier(c, definition); if (editorial) { const result = await withHttpErrors( @@ -1309,10 +1381,8 @@ export const buildContentRoutes = < }, }, handler: async c => { - const id = identifier(c); - // Serial until the ID strategies land, and `identifier` has already - // proven it a positive integer. - const contentId = id as ContentIdOf; + const id = contentIdentifier(c, definition); + const contentId = id; const { expectedVersion } = await readOptionalJson(c, visibilityBody); const actor = resolveContentActor(c); @@ -1388,7 +1458,7 @@ export const buildContentRoutes = < }); const revisionParams = z.object({ - id: z.coerce.number(), + id: schemas.params.shape.id, revisionId: z.coerce.number(), }); @@ -1448,10 +1518,13 @@ export const buildContentRoutes = < // Metadata only. Opening the history must not drag every historical // snapshot of a long article across the wire; the detail route loads one // on demand. - const page = await editorialService(c).revisions.list(identifier(c), { - cursor, - limit: first, - }); + const page = await editorialService(c).revisions.list( + contentIdentifier(c, definition), + { + cursor, + limit: first, + }, + ); return c.json(page, 200); }, @@ -1476,7 +1549,7 @@ export const buildContentRoutes = < // revisions table is shared by every editorial content type in the // install, so an id on its own proves nothing about ownership. const revision = await editorialService(c).revisions.findById( - identifier(c), + contentIdentifier(c, definition), revisionIdentifier(c), ); if (!revision) { @@ -1515,7 +1588,7 @@ export const buildContentRoutes = < }, }, handler: async c => { - const id = identifier(c); + const id = contentIdentifier(c, definition); const revisionId = revisionIdentifier(c); const { expectedVersion } = await readJson( c, @@ -1605,7 +1678,7 @@ export const buildContentRoutes = < const build = model.deliveryService; if (!build) throw notFound(definition); - const id = identifier(c); + const id = contentIdentifier(c, definition); const locale = c.req.query("locale"); const delivery = build(c, { pluginId }); @@ -1677,7 +1750,7 @@ export const buildContentRoutes = < // a record that exists and one that does not. assertPreviewIsServable(); - const id = identifier(c); + const id = contentIdentifier(c, definition); const row = await model.service(c).findById(id); if (!row) throw notFound(definition); @@ -1788,7 +1861,9 @@ export const buildContentRoutes = < }, }, handler: async c => { - const edges = await schedulesModel(c).listForItem(identifier(c)); + const edges = await schedulesModel(c).listForItem( + contentIdentifier(c, definition), + ); return c.json( { edges, hasCronAdapter: c.get("core")?.hasCronAdapter ?? false }, @@ -1833,7 +1908,7 @@ export const buildContentRoutes = < }, }, handler: async c => { - const id = identifier(c); + const id = contentIdentifier(c, definition); const { action, scheduledFor } = await readJson( c, z.strictObject({ @@ -1890,7 +1965,7 @@ export const buildContentRoutes = < description: `Cancel a pending schedule for one ${name}`, request: { params: z.object({ - id: z.coerce.number(), + id: schemas.params.shape.id, scheduleId: z.coerce.number(), }), }, @@ -1901,7 +1976,7 @@ export const buildContentRoutes = < }, }, handler: async c => { - const id = identifier(c); + const id = contentIdentifier(c, definition); const scheduleId = Number(c.req.param("scheduleId")); if (!Number.isInteger(scheduleId) || scheduleId <= 0) { throw new HTTPException(400, { @@ -1973,7 +2048,7 @@ export const buildContentRoutes = < }, }, handler: async c => { - const id = identifier(c); + const id = contentIdentifier(c, definition); const { expectedVersion } = await readJson(c, deleteEnvelope); // The history outlives the record: a final `delete` revision is what makes @@ -2016,7 +2091,7 @@ export const buildContentRoutes = < }, }, handler: async c => { - const id = identifier(c); + const id = contentIdentifier(c, definition); const row = await withHttpErrors("delete", async () => model.service(c).delete(id), diff --git a/packages/vitnode/src/content/server/schedule-effects.ts b/packages/vitnode/src/content/server/schedule-effects.ts index 20b1d2ef07..93eb9423e2 100644 --- a/packages/vitnode/src/content/server/schedule-effects.ts +++ b/packages/vitnode/src/content/server/schedule-effects.ts @@ -3,6 +3,7 @@ import type { Context } from "hono"; import { z } from "zod"; import type { ContentLocaleInvalidation } from "../cache"; +import type { ContentId } from "../ids"; import type { AnyContentTypeDefinition } from "../types"; import type { ContentEditorialOutcome } from "./editorial-service"; import type { AnyContentModel } from "./model"; @@ -11,7 +12,8 @@ import { contentLocaleInvalidations, diffContentPublicLocaleStates, } from "../cache"; -import { CONTENT_SCHEDULE_ACTIONS } from "../const"; +import { CONTENT_ID_KEY_LENGTH, CONTENT_SCHEDULE_ACTIONS } from "../const"; +import { parseContentId } from "../ids"; import { contentEditorialEffects } from "./editorial-effects"; import { contentDefinitionOf, findContentModel } from "./model"; import { contentPublicLocaleStates } from "./public-locales"; @@ -23,12 +25,13 @@ const scheduledLocales = async ( c: Context, model: AnyContentModel, payload: ContentScheduleEffectsPayload, + itemId: ContentId, row: Record, ): Promise => { - const after = await contentPublicLocaleStates(c, model, payload.itemId, { + const after = await contentPublicLocaleStates(c, model, itemId, { row, }); - const before = await contentPublicLocaleStates(c, model, payload.itemId, { + const before = await contentPublicLocaleStates(c, model, itemId, { row: { ...row, // `publishedAt` only has to be a past instant for the predicate; the real @@ -49,7 +52,14 @@ const scheduledLocales = async ( export const contentScheduleEffectsPayloadSchema = z.object({ changedFields: z.array(z.string()), contentTypeId: z.string().min(1), - itemId: z.number().int().positive(), + /** + * A number for a `serial` record, the canonical string for a `uuid` or + * `bigint` one - re-read under the content type's strategy before use. + */ + itemId: z.union([ + z.number().int().positive(), + z.string().min(1).max(CONTENT_ID_KEY_LENGTH), + ]), operation: z.enum(CONTENT_SCHEDULE_ACTIONS), pluginId: z.string().min(1), previousSlug: z.string().nullable(), @@ -125,6 +135,20 @@ export const runContentScheduleEffects = async ( const definition = contentDefinitionOf(entry.model); const row = reviveDates(definition, payload.row); + // Written by the executor under the same definition, so a miss means the + // content type's `idStrategy` changed between the two runs: nothing this run + // could announce would name the right record. + const itemId = parseContentId(definition.idStrategy, payload.itemId); + if (itemId === null) { + await recordContentScheduleEffectsError( + c.get("db"), + payload.scheduleId, + `Scheduled ${payload.operation} names ${JSON.stringify(payload.itemId)}, which is not a ${definition.idStrategy} identifier of "${payload.contentTypeId}".`, + ); + + return { status: "unregistered" }; + } + const outcome: ContentEditorialOutcome = { changed: true, changedFields: payload.changedFields, @@ -179,14 +203,16 @@ export const runContentScheduleEffects = async ( }, } : {}), - id: payload.itemId, + id: itemId, isPublic, // A scheduled transition moves the *record*, and the record's publication // state gates every language - so every locale that had a page, or has one // now, is expired. Absent for a content type that is not localized, which // leaves the flat fields below as the whole input, exactly as before. ...(definition.localization.enabled && definition.publicApi.enabled - ? { locales: await scheduledLocales(c, entry.model, payload, row) } + ? { + locales: await scheduledLocales(c, entry.model, payload, itemId, row), + } : {}), mode: "immediate", // Both, because a transition that moved the URL has to expire the one it diff --git a/packages/vitnode/src/content/server/schedules-model.ts b/packages/vitnode/src/content/server/schedules-model.ts index e2e6af4a5a..804f07b322 100644 --- a/packages/vitnode/src/content/server/schedules-model.ts +++ b/packages/vitnode/src/content/server/schedules-model.ts @@ -2,6 +2,7 @@ import type { Context } from "hono"; import { and, desc, eq, inArray, lt, notInArray, sql } from "drizzle-orm"; +import type { ContentId } from "../ids"; import type { ContentSchedule, ContentScheduleAction, @@ -14,6 +15,7 @@ import { core_content_schedules } from "../../database/content"; import { core_users } from "../../database/users"; import { CONTENT_QUEUE_TASK_SCHEDULE, CONTENT_SCHEDULE_CODES } from "../const"; import { ContentScheduleError } from "../errors"; +import { contentIdKey } from "../ids"; import { contentScheduleTimingError } from "../schedules"; /** A schedule row claimed for execution, with everything the handler needs. */ @@ -22,7 +24,12 @@ export interface ClaimedContentSchedule { contentTypeId: string; createdBy: null | number; id: number; - itemId: number; + /** + * The record's storage key (`contentIdKey`), exactly as the shared table holds + * it. Decode it with `contentIdFromKey(definition.idStrategy, itemId)` once the + * content type is known. + */ + itemId: string; pluginId: string; } @@ -113,14 +120,14 @@ export const recordContentScheduleEffectsError = async ( export interface ContentSchedulesModel { cancel: ( - itemId: number, + itemId: ContentId, scheduleId: number, ) => Promise; /** Pending and recent schedules for one record, newest first. */ - listForItem: (itemId: number) => Promise; + listForItem: (itemId: ContentId) => Promise; /** Pending rows only, for the ordering rule. */ pendingForItem: ( - itemId: number, + itemId: ContentId, tx?: ContentDatabase, ) => Promise<{ action: ContentScheduleAction; scheduledFor: Date }[]>; recordError: (scheduleId: number, message: string) => Promise; @@ -131,7 +138,7 @@ export interface ContentSchedulesModel { schedule: (input: { action: ContentScheduleAction; actorUserId: null | number; - itemId: number; + itemId: ContentId; now?: Date; scheduledFor: Date; }) => Promise<{ generation: number; id: number; scheduledFor: Date }>; @@ -151,14 +158,14 @@ export const createContentSchedulesModel = ({ }): ContentSchedulesModel => { const contentTypeId = definition.id; - const scope = (itemId: number) => + const scope = (itemId: ContentId) => and( eq(core_content_schedules.pluginId, pluginId), eq(core_content_schedules.contentTypeId, contentTypeId), - eq(core_content_schedules.itemId, itemId), + eq(core_content_schedules.itemId, contentIdKey(itemId)), ); - const pendingForItem = async (itemId: number, tx?: ContentDatabase) => + const pendingForItem = async (itemId: ContentId, tx?: ContentDatabase) => await (tx ?? c.get("db")) .select({ action: core_content_schedules.action, @@ -280,7 +287,7 @@ export const createContentSchedulesModel = ({ contentTypeId, createdBy: actorUserId, generation, - itemId, + itemId: contentIdKey(itemId), pluginId, scheduledFor, }) diff --git a/packages/vitnode/src/content/server/search-document.ts b/packages/vitnode/src/content/server/search-document.ts index ff9d4dbdd7..42d0eec0c2 100644 --- a/packages/vitnode/src/content/server/search-document.ts +++ b/packages/vitnode/src/content/server/search-document.ts @@ -6,6 +6,7 @@ import { isContentPubliclyVisible, isContentTranslationPubliclyVisible, } from "../cache"; +import { isContentId } from "../ids"; import { partitionContentFields } from "../localization"; import { readContentPath, splitContentFieldPath } from "../paths"; import { isRichTextDocument } from "../rich-text/document"; @@ -92,8 +93,10 @@ export const contentSearchDocument = ( const values = row as Record; + // Exactly the representation the content type's strategy reads back: a + // `uuid` or `bigint` id is a string, a `serial` one a number. const itemId = values.id; - if (typeof itemId !== "number") return null; + if (!isContentId(definition.idStrategy, itemId)) return null; const title = normalize(readSearchValue(values, search.titleField)); if (title === "") return null; diff --git a/packages/vitnode/src/content/server/search-indexer.ts b/packages/vitnode/src/content/server/search-indexer.ts index 02f7fb1189..7a92c8122a 100644 --- a/packages/vitnode/src/content/server/search-indexer.ts +++ b/packages/vitnode/src/content/server/search-indexer.ts @@ -13,11 +13,13 @@ import type { SearchIndexer, SearchIndexerPage, } from "../../api/models/search"; +import type { ContentId } from "../ids"; import type { AnyContentTypeDefinition, ContentFieldMap } from "../types"; import type { ContentAdvancedStore } from "./advanced-store"; import type { ContentModel } from "./model"; import { ContentEngineError } from "../errors"; +import { contentIdKey, parseContentId } from "../ids"; import { partitionContentFields } from "../localization"; import { contentColumnsToValues, @@ -119,25 +121,49 @@ const resolveSearchSources = ( return sources; }; +/** + * The indexed collections of one page, keyed by storage key so a row reads its + * own back whatever its strategy. + */ const loadSearchCollections = async ({ advanced, c, + definition, itemIds, wanted, }: { advanced: ContentAdvancedStore; c: Context; - itemIds: readonly number[]; + definition: AnyContentTypeDefinition; + itemIds: readonly unknown[]; wanted: readonly string[]; -}): Promise>> => { +}): Promise>> => { if (wanted.length === 0) return new Map(); - const unique = [...new Set(itemIds.filter(id => Number.isInteger(id)))]; - if (unique.length === 0) return new Map(); + const unique = new Map(); + for (const value of itemIds) { + const id = parseContentId(definition.idStrategy, value); + if (id !== null) unique.set(contentIdKey(id), id); + } + if (unique.size === 0) return new Map(); - return await advanced.loadMany(unique, c.get("db"), wanted); + const loaded = await advanced.loadMany( + [...unique.values()], + c.get("db"), + wanted, + ); + + return new Map( + [...loaded].map(([id, values]) => [contentIdKey(id), values] as const), + ); }; +/** The storage key of a row's own id, for reading a collection map. */ +const rowKey = (row: Record): string => + typeof row.id === "number" || typeof row.id === "string" + ? contentIdKey(row.id) + : ""; + const assertPublicationColumns = ( definition: AnyContentTypeDefinition, columns: Record, @@ -180,7 +206,10 @@ export const createContentSearchIndexer = < ].map(name => [name, columns[name]]), ); - const cursors = new WeakMap(); + // The id strategy's own representation: a number for `serial`, a string for + // `uuid` and string-mode `bigint` - never `Number(...)`, which would round a + // bigint above 2^53 and lose the page boundary. + const cursors = new WeakMap(); return { itemType: definition.id, @@ -208,14 +237,18 @@ export const createContentSearchIndexer = < .limit(limit); const last = rows.at(-1); - if (last && typeof last.id === "number") cursors.set(c, last.id); + const lastId = last + ? parseContentId(definition.idStrategy, last.id) + : null; + if (lastId !== null) cursors.set(c, lastId); // One batch for the whole page, and only the collections the search // configuration names - never one query per document. const collections = await loadSearchCollections({ advanced, c, - itemIds: rows.map(row => Number((row as Record).id)), + definition, + itemIds: rows.map(row => (row as Record).id), wanted: sources.collections, }); @@ -229,7 +262,7 @@ export const createContentSearchIndexer = < // reads: it is handed `seo.description`, and a flat `seoDescription` // column would resolve to nothing. ...contentColumnsToValues(sources.sharedGroups, values), - ...collections.get(Number(values.id)), + ...collections.get(rowKey(values)), }, { pluginId, routing: contentLocaleRouting(c) }, ); @@ -274,7 +307,7 @@ export const createContentLocalizedSearchIndexer = < const cursors = new WeakMap< Context, - { itemId: number; languageId: number } + { itemId: ContentId; languageId: number } >(); return { @@ -330,9 +363,12 @@ export const createContentLocalizedSearchIndexer = < .limit(limit); const last = page.at(-1) as Record | undefined; - if (last) { + const lastId = last + ? parseContentId(definition.idStrategy, last.id) + : null; + if (last && lastId !== null) { cursors.set(c, { - itemId: last.id as number, + itemId: lastId, languageId: last._languageId as number, }); } @@ -348,7 +384,8 @@ export const createContentLocalizedSearchIndexer = < const collections = await loadSearchCollections({ advanced, c, - itemIds: page.map(row => Number((row as Record).id)), + definition, + itemIds: page.map(row => (row as Record).id), wanted: sources.collections, }); @@ -370,7 +407,7 @@ export const createContentLocalizedSearchIndexer = < base: { ...values, ...contentColumnsToValues(sources.sharedGroups, values), - ...collections.get(Number(values.id)), + ...collections.get(rowKey(values)), }, locale, translation: { diff --git a/packages/vitnode/src/content/server/search-sync.ts b/packages/vitnode/src/content/server/search-sync.ts index af12678a0b..33ff597901 100644 --- a/packages/vitnode/src/content/server/search-sync.ts +++ b/packages/vitnode/src/content/server/search-sync.ts @@ -3,9 +3,11 @@ import type { Context } from "hono"; import { eq } from "drizzle-orm"; +import type { ContentId } from "../ids"; import type { AnyContentTypeDefinition } from "../types"; import type { AnyContentModel } from "./model"; +import { parseContentId } from "../ids"; import { partitionContentFields } from "../localization"; import { contentColumnsToValues, contentStorageColumns } from "../paths"; import { @@ -100,10 +102,12 @@ export const syncContentSearch = async ( input: ContentSearchSyncInput, ): Promise => { const values = input.row as Record; - const itemId = typeof values.id === "number" ? values.id : 0; - const documentId = contentSearchDocumentId(definition, itemId); + // Read under the content type's strategy: a `uuid` or `bigint` row's id is a + // string, and treating it as "no id" would silently never index it. + const itemId = parseContentId(definition.idStrategy, values.id); + const documentId = contentSearchDocumentId(definition, itemId ?? 0); - if (!definition.search.enabled || itemId === 0) { + if (!definition.search.enabled || itemId === null) { return { action: "skip", documentId }; } @@ -214,7 +218,7 @@ interface TranslationRecord { const readTranslations = async ( c: Context, model: AnyContentModel, - itemId: number, + itemId: ContentId, ): Promise => { const columns: null | Record = model.translationColumns; const table: null | PgTable = model.translationTable; @@ -274,12 +278,12 @@ export const syncContentLocalizedSearch = async ( ): Promise => { const definition = contentDefinitionOf(model); const values = input.row as Record; - const itemId = typeof values.id === "number" ? values.id : 0; + const itemId = parseContentId(definition.idStrategy, values.id); if ( !definition.search.enabled || !definition.localization.enabled || - itemId === 0 + itemId === null ) { return []; } @@ -395,10 +399,10 @@ export const syncContentLocalizedSearch = async ( export const contentSearchAdvancedValues = async ( c: Context, model: AnyContentModel, - itemId: number, + itemId: ContentId, ): Promise | undefined> => { const wanted = contentSearchIndexedCollections(contentDefinitionOf(model)); - if (wanted.length === 0 || itemId === 0) return undefined; + if (wanted.length === 0) return undefined; return await model.service(c).advancedFields(itemId, wanted); }; @@ -424,7 +428,7 @@ const write = async ( action: "delete" | "upsert"; documentId: string; input: { operation: ContentSearchOperation; pluginId?: string }; - itemId: number; + itemId: ContentId; run: () => Promise; }, ): Promise => { diff --git a/packages/vitnode/src/content/server/service.ts b/packages/vitnode/src/content/server/service.ts index 02d577638d..6c67fc94c3 100644 --- a/packages/vitnode/src/content/server/service.ts +++ b/packages/vitnode/src/content/server/service.ts @@ -10,6 +10,7 @@ import type { Context } from "hono"; import { and, eq, inArray, ne, or, sql } from "drizzle-orm"; import type { PaginationCursorColumn } from "../../api/lib/with-pagination"; +import type { ContentId, ContentIdOf } from "../ids"; import type { ContentSchemas } from "../schemas"; import type { AnyContentTypeDefinition, @@ -17,11 +18,13 @@ import type { ContentChangedPath, ContentCreateInput, ContentDetail, + ContentFieldsOf, ContentFilterInput, ContentInnerFieldsOf, ContentLocalizedValues, ContentOrderableFieldName, ContentReferenceFieldName, + ContentReferenceIdOf, ContentRelationCollectionName, ContentRepeatableFieldName, ContentRepeatableInputRow, @@ -31,7 +34,10 @@ import type { ContentValuesOf, } from "../types"; import type { ContentAdvancedStore } from "./advanced-store"; -import type { ContentDuplicationMethods } from "./duplicate"; +import type { + ContentDuplicateTranslationSource, + ContentDuplicationMethods, +} from "./duplicate"; import type { ContentPickerTarget } from "./references"; import type { ContentTranslationModel } from "./translation-model"; import type { @@ -52,6 +58,7 @@ import { CONTENT_VISIBILITY_FIELDS, } from "../const"; import { ContentEngineError } from "../errors"; +import { contentRelationStrategy, requireContentId } from "../ids"; import { partitionContentFields } from "../localization"; import { contentColumnsToValues, contentStorageColumns } from "../paths"; import { orderableColumns } from "../registry"; @@ -138,63 +145,71 @@ export interface ContentUpdateResult { row: ContentSelect; } -export interface ContentRelationMethods { +/** + * The collection API of one to-many reference. `itemId` is the owner's id; + * `TRelatedId` is the target's - a relation target's own strategy, or a number + * for users and files. + */ +export interface ContentRelationMethods { /** Adds one target. A target already present is a no-op. */ add: ( - itemId: number, - relatedItemId: number, + itemId: ContentIdOf, + relatedItemId: TRelatedId, options?: ContentWriteOptions, ) => Promise | null>; /** The current targets, in stored order. */ - get: (itemId: number, options?: ContentServiceOptions) => Promise; + get: ( + itemId: ContentIdOf, + options?: ContentServiceOptions, + ) => Promise; /** Removes one target. A target that is not there is a no-op. */ remove: ( - itemId: number, - relatedItemId: number, + itemId: ContentIdOf, + relatedItemId: TRelatedId, options?: ContentWriteOptions, ) => Promise | null>; reorder: ( - itemId: number, - relatedItemIds: readonly number[], + itemId: ContentIdOf, + relatedItemIds: readonly TRelatedId[], options?: ContentWriteOptions, ) => Promise | null>; /** Replaces the whole set. */ set: ( - itemId: number, - relatedItemIds: readonly number[], + itemId: ContentIdOf, + relatedItemIds: readonly TRelatedId[], options?: ContentWriteOptions, ) => Promise | null>; } export interface ContentRepeatableMethods { create: ( - itemId: number, + itemId: ContentIdOf, values: ContentValuesOf>, options?: ContentWriteOptions, ) => Promise | null>; /** Removes one child by its stable identifier. */ delete: ( - itemId: number, + itemId: ContentIdOf, childId: number, options?: ContentWriteOptions, ) => Promise | null>; /** The current children, in position order, each with its identifier. */ list: ( - itemId: number, + itemId: ContentIdOf, options?: ContentServiceOptions, ) => Promise< ContentRepeatableRow>[] >; /** Rearranges the existing children. Refuses a non-permutation. */ reorder: ( - itemId: number, + itemId: ContentIdOf, childIds: readonly number[], options?: ContentWriteOptions, ) => Promise | null>; set: ( - itemId: number, + itemId: ContentIdOf, rows: readonly ContentRepeatableInputRow< ContentInnerFieldsOf >[], @@ -202,7 +217,7 @@ export interface ContentRepeatableMethods { ) => Promise | null>; update: ( - itemId: number, + itemId: ContentIdOf, childId: number, values: Partial>>, options?: ContentWriteOptions, @@ -228,12 +243,12 @@ export interface ContentPublicationMethods { * transition and never rewrites it. `null` when the row does not exist. */ publish: ( - id: number, + id: ContentIdOf, options?: ContentServiceOptions, ) => Promise | null>; /** Idempotent. Flips `status` only - `publishedAt` is left alone. */ unpublish: ( - id: number, + id: ContentIdOf, options?: ContentServiceOptions, ) => Promise | null>; } @@ -250,12 +265,12 @@ export type ContentService = ContentServiceBase & export interface ContentServiceBase { /** The advanced collections of one record. Two queries per collection field. */ advanced: ( - id: number, + id: ContentIdOf, options?: ContentServiceOptions, ) => Promise>; advancedFields: ( - id: number, + id: ContentIdOf, fields: readonly string[], options?: ContentServiceOptions, ) => Promise>; @@ -265,16 +280,16 @@ export interface ContentServiceBase { options?: ContentServiceOptions, ) => Promise>; delete: ( - id: number, + id: ContentIdOf, options?: ContentServiceOptions, ) => Promise | null>; findById: ( - id: number, + id: ContentIdOf, options?: ContentServiceOptions, ) => Promise | null>; findDetail: ( - id: number, + id: ContentIdOf, options?: ContentServiceOptions, ) => Promise | null>; findMany: (args?: ContentFindManyArgs) => Promise<{ @@ -283,20 +298,27 @@ export interface ContentServiceBase { }>; findRowById: ( - id: number, + id: ContentIdOf, options?: ContentServiceOptions, ) => Promise | null>; + /** + * Picker options for one reference field. `value` is the target's identifier: + * a number for a user or a serial target, a string for a `uuid` or `bigint` + * one. + */ options: ( field: ContentReferenceFieldName, search?: string, - ids?: readonly number[], - ) => Promise<{ color?: string; label: string; value: number }[]>; + ids?: readonly ContentId[], + ) => Promise<{ color?: string; label: string; value: ContentId }[]>; - relations: Record< - ContentRelationCollectionName, - ContentRelationMethods - >; + relations: { + [K in ContentRelationCollectionName]: ContentRelationMethods< + TDefinition, + ContentReferenceIdOf[K]> + >; + }; /** * Typed repeatable operations, keyed by the content type's actual repeatable * field names - each one carrying its own child shape. @@ -309,7 +331,7 @@ export interface ContentServiceBase { }; /** Throws a `ZodError` if `values` does not satisfy `schemas.update`. */ update: ( - id: number, + id: ContentIdOf, values: ContentUpdateInput, options?: ContentServiceOptions, ) => Promise | null>; @@ -352,8 +374,8 @@ export const createContentService = < const filterableFields = { ...fields, ...collectionFields }; const store = advanced; const contentTypeId = definition.id; - // `buildSystemColumns` always makes `id` a `serial`, which is what - // `withPagination` needs to type its cursor. + // `serial`, `uuid` or a string-mode `bigint` - all three are cursor kinds + // `withPagination` understands. const primaryCursor = columns.id as PaginationCursorColumn; const orderable = orderableColumns(definition); const publication = definition.publication.enabled; @@ -574,7 +596,7 @@ export const createContentService = < }; const readOne = async ( - id: number, + id: ContentId, database: ContentDatabase, ): Promise> => { const [row] = await database @@ -601,7 +623,7 @@ export const createContentService = < * row" - a distinction the route turns into 200 vs 404. */ const transition = async ( - id: number, + id: ContentId, options: ContentServiceOptions | undefined, values: Record, guard: SQL, @@ -664,7 +686,7 @@ export const createContentService = < * are never in the `SET`: hiding is a separate axis from publication. */ const visibilityTransition = async ( - id: number, + id: ContentId, action: ContentVisibilityAction, options: ContentVisibilityOptions | undefined, ): Promise | null> => @@ -711,13 +733,11 @@ export const createContentService = < }; }); - // Serial today, so the identifier is a number at runtime; `ContentIdOf` keeps - // the signature honest for the strategies that are not. const hideableMethods: ContentVisibilityMethods = { hide: async (id, options) => - await visibilityTransition(id as number, "hide", options), + await visibilityTransition(id, "hide", options), unhide: async (id, options) => - await visibilityTransition(id as number, "unhide", options), + await visibilityTransition(id, "unhide", options), }; /** @@ -765,7 +785,7 @@ export const createContentService = < */ const lockRow = async ( tx: ContentDatabase, - id: number, + id: ContentId, { force = false }: { force?: boolean } = {}, ): Promise => { // A content type with no collections has no read-modify-write to protect, so @@ -812,8 +832,12 @@ export const createContentService = < // In the same transaction as the row it belongs to: a create that // committed its categories and rolled back its article would leave // junction rows pointing at nothing. - if (store?.enabled && typeof row.id === "number") { - await store.write(tx, row.id, parsed); + if (store?.enabled) { + await store.write( + tx, + requireContentId(definition.idStrategy, row.id, contentTypeId), + parsed, + ); } return toRow(row); @@ -1037,7 +1061,10 @@ export const createContentService = < .limit(ids ? ids.length : CONTENT_OPTIONS_LIMIT); return rows.map(row => { - const value = Number(row.value); + // A user and a serial target read back as numbers; a `uuid` and a + // string-mode `bigint` as strings - which is what they stay. + const value: ContentId = + typeof row.value === "number" ? row.value : String(row.value); const entry = row as Record; const color = target.colorColumn ? toLabel(entry.color) : null; @@ -1059,7 +1086,10 @@ export const createContentService = < }); }, - relations: mutableRelations, + // Keyed by the same runtime field list the conditional type is computed + // from; each entry's target ids follow that field's own strategy. + relations: + mutableRelations as unknown as ContentServiceBase["relations"], repeatable: mutableRepeatables as ContentService["repeatable"], @@ -1087,7 +1117,7 @@ export const createContentService = < */ const applyPatch = async ( tx: ContentDatabase, - id: number, + id: ContentId, patch: Record, ): Promise | null> => { { @@ -1151,7 +1181,7 @@ export const createContentService = < * serialised by exactly the same primitive. */ const runCollection = async ( - itemId: number, + itemId: ContentId, field: string, compute: (current: unknown[]) => unknown[], options: ContentServiceOptions | undefined, @@ -1171,7 +1201,7 @@ export const createContentService = < }); const collectionApi = { - read: async (itemId: number, field: string, options: unknown) => { + read: async (itemId: ContentId, field: string, options: unknown) => { const loaded = await store?.load( itemId, db(options as ContentServiceOptions | undefined), @@ -1183,7 +1213,7 @@ export const createContentService = < }, run: runCollection, write: async ( - itemId: number, + itemId: ContentId, field: string, next: readonly unknown[], options: ContentServiceOptions | undefined, @@ -1191,7 +1221,7 @@ export const createContentService = < // `set` replaces the whole collection, so it never reads and cannot lose a // concurrent write. It still goes through `update`, which locks. await service.update( - itemId, + itemId as ContentIdOf, { [field]: [...next] } as ContentUpdateInput, options, ), @@ -1206,10 +1236,15 @@ export const createContentService = < ); for (const field of relations) { + const fieldValue = definition.fields[field]; mutableRelations[field] = buildContentRelationOperations({ api: collectionApi, contentTypeId, field, + strategy: + fieldValue.kind === "relation" + ? contentRelationStrategy(definition, fieldValue) + : "serial", }); } for (const field of repeatables) { @@ -1217,7 +1252,7 @@ export const createContentService = < api: collectionApi, contentTypeId, field, - }) as ContentRepeatableMethods; + }) as unknown as ContentRepeatableMethods; } // One transaction, through `service.create` and the translation model's own @@ -1236,7 +1271,10 @@ export const createContentService = < translation && translationModel ? { columns: translation.columns, - model: translationModel, + // Erased to `ContentId`: the duplicate only ever hands + // back the canonical id it read off this content type's row. + model: + translationModel as unknown as () => ContentDuplicateTranslationSource, table: translation.table, } : undefined, @@ -1248,7 +1286,12 @@ export const createContentService = < await service.create(values as ContentCreateInput, { tx, }), - idOf: row => (row as { id: number }).id, + idOf: row => + requireContentId( + definition.idStrategy, + (row as { id: unknown }).id, + contentTypeId, + ), translation: async (itemId, locale, values, tx) => { if (!translationModel) { throw new ContentEngineError( @@ -1258,7 +1301,7 @@ export const createContentService = < } return await translationModel().create( - itemId, + itemId as ContentIdOf, locale, values as ContentLocalizedValues, { tx }, diff --git a/packages/vitnode/src/content/server/slug-history-model.ts b/packages/vitnode/src/content/server/slug-history-model.ts index 2a84222c7b..ee995f7c52 100644 --- a/packages/vitnode/src/content/server/slug-history-model.ts +++ b/packages/vitnode/src/content/server/slug-history-model.ts @@ -3,6 +3,7 @@ import type { Context } from "hono"; import { and, asc, desc, eq, inArray, isNull, sql } from "drizzle-orm"; +import type { ContentId, ContentIdStrategy } from "../ids"; import type { ContentLocaleRouting } from "../public-url"; import type { AnyContentTypeDefinition } from "../types"; import type { ContentDatabase } from "./service"; @@ -10,10 +11,15 @@ import type { ContentDatabase } from "./service"; import { core_content_slug_history } from "../../database/content"; import { contentDeliveryPath } from "../delivery"; import { ContentDeliverySlugReserved } from "../errors"; +import { contentIdFromKey, contentIdKey } from "../ids"; export interface ContentSlugHistoryEntry { createdAt: Date; - itemId: number; + /** + * The owning record, read back under the content type's strategy. The table + * stores `contentIdKey(id)`. + */ + itemId: ContentId; /** `null` for a shared slug - see `core_content_slug_history`. */ languageId: null | number; path: string; @@ -22,7 +28,7 @@ export interface ContentSlugHistoryEntry { } export interface ContentSlugHistoryTarget { - itemId: number; + itemId: ContentId; /** `null` for a shared slug - see `core_content_slug_history`. */ languageId: null | number; /** The canonical `core_languages.code`, or `null` when the slug is shared. */ @@ -45,7 +51,7 @@ export interface ContentSlugHistoryModel { ) => Promise<{ created: boolean }>; list: ( - args: { itemId: number; languageId?: null | number; limit?: number }, + args: { itemId: ContentId; languageId?: null | number; limit?: number }, database?: ContentDatabase, ) => Promise; @@ -75,16 +81,21 @@ const languageCondition = ( column: typeof core_content_slug_history.languageId, ): SQL => (languageId === null ? isNull(column) : eq(column, languageId)); -const toEntry = (row: { - createdAt: Date; - itemId: number; - languageId: null | number; - path: string; - retiredAt: Date | null; - slug: string; -}): ContentSlugHistoryEntry => ({ +const toEntry = ( + strategy: ContentIdStrategy, + row: { + createdAt: Date; + itemId: string; + languageId: null | number; + path: string; + retiredAt: Date | null; + slug: string; + }, +): ContentSlugHistoryEntry => ({ createdAt: row.createdAt, - itemId: row.itemId, + // A key the strategy cannot read stays the key: an owner that no live record + // can match, which is what a reservation by a vanished id should be. + itemId: contentIdFromKey(strategy, row.itemId) ?? row.itemId, languageId: row.languageId, path: row.path, retiredAt: row.retiredAt, @@ -131,7 +142,7 @@ export const createContentSlugHistoryModel = ({ const [row] = lock ? await query.for("update") : await query; - return row ? toEntry(row) : null; + return row ? toEntry(definition.idStrategy, row) : null; }; const claim = async ( @@ -146,7 +157,14 @@ export const createContentSlugHistoryModel = ({ ): Promise<{ created: boolean }> => { const [inserted] = await tx .insert(core_content_slug_history) - .values({ contentTypeId, itemId, languageId, path, pluginId, slug }) + .values({ + contentTypeId, + itemId: contentIdKey(itemId), + languageId, + path, + pluginId, + slug, + }) .onConflictDoNothing() .returning({ id: core_content_slug_history.id }); @@ -160,7 +178,7 @@ export const createContentSlugHistoryModel = ({ // commit - and under a stricter isolation level it means somebody else has the // address and we simply cannot see them yet. Either way this transaction did // not get it, and refusing is the only answer that is never wrong. - if (owner?.itemId !== itemId) { + if (owner === null || contentIdKey(owner.itemId) !== contentIdKey(itemId)) { throw new ContentDeliverySlugReserved({ contentTypeId, locale, slug }); } @@ -170,7 +188,12 @@ export const createContentSlugHistoryModel = ({ return { assertAvailable: async (tx, { itemId, languageId, locale, slug }) => { const owner = await findOwner(tx, { languageId, slug }); - if (owner === null || owner.itemId === itemId) return; + if ( + owner === null || + contentIdKey(owner.itemId) === contentIdKey(itemId) + ) { + return; + } throw new ContentDeliverySlugReserved({ contentTypeId, locale, slug }); }, @@ -181,7 +204,10 @@ export const createContentSlugHistoryModel = ({ ensureCurrent: async (tx, args) => await claim(tx, args), list: async ({ itemId, languageId, limit }, database) => { - const conditions = [scope, eq(core_content_slug_history.itemId, itemId)]; + const conditions = [ + scope, + eq(core_content_slug_history.itemId, contentIdKey(itemId)), + ]; if (languageId !== undefined) { conditions.push( languageCondition(languageId, core_content_slug_history.languageId), @@ -202,7 +228,7 @@ export const createContentSlugHistoryModel = ({ ) .limit(Math.min(limit ?? HISTORY_LIST_LIMIT, HISTORY_LIST_LIMIT)); - return rows.map(toEntry); + return rows.map(row => toEntry(definition.idStrategy, row)); }, owner: async (args, database) => @@ -224,7 +250,7 @@ export const createContentSlugHistoryModel = ({ .where( and( scope, - eq(core_content_slug_history.itemId, itemId), + eq(core_content_slug_history.itemId, contentIdKey(itemId)), eq(core_content_slug_history.slug, slug), languageCondition(languageId, core_content_slug_history.languageId), ), @@ -240,7 +266,7 @@ export const createContentSlugHistoryModel = ({ .where( and( scope, - eq(core_content_slug_history.itemId, itemId), + eq(core_content_slug_history.itemId, contentIdKey(itemId)), eq(core_content_slug_history.slug, slug), languageCondition(languageId, core_content_slug_history.languageId), // Only an *active* row is retired. A slug already marked historical @@ -256,6 +282,7 @@ export const createContentSlugHistoryModel = ({ }; }; +/** The live historical path per record, keyed by `contentIdKey(itemId)`. */ export const contentSlugHistoryCurrentPaths = async ( database: ContentDatabase, { @@ -264,10 +291,10 @@ export const contentSlugHistoryCurrentPaths = async ( languageId, }: { contentTypeId: string; - itemIds: readonly number[]; + itemIds: readonly ContentId[]; languageId: null | number; }, -): Promise> => { +): Promise> => { if (itemIds.length === 0) return new Map(); const rows = await database @@ -279,7 +306,7 @@ export const contentSlugHistoryCurrentPaths = async ( .where( and( eq(core_content_slug_history.contentTypeId, contentTypeId), - inArray(core_content_slug_history.itemId, [...itemIds]), + inArray(core_content_slug_history.itemId, itemIds.map(contentIdKey)), languageCondition(languageId, core_content_slug_history.languageId), isNull(core_content_slug_history.retiredAt), ), diff --git a/packages/vitnode/src/content/server/table.ts b/packages/vitnode/src/content/server/table.ts index 4022825111..5c73f2161d 100644 --- a/packages/vitnode/src/content/server/table.ts +++ b/packages/vitnode/src/content/server/table.ts @@ -12,6 +12,7 @@ import { uniqueIndex, } from "drizzle-orm/pg-core"; +import type { ContentIdStrategy } from "../ids"; import type { AnyContentTypeDefinition, ContentFieldMap, @@ -32,6 +33,7 @@ import { CONTENT_VISIBILITY_FIELDS, } from "../const"; import { ContentEngineError } from "../errors"; +import { contentIdSqlType } from "../ids"; import { partitionContentFields } from "../localization"; import { contentStorageColumns } from "../paths"; import { @@ -42,11 +44,77 @@ import { buildVisibilityColumns, } from "./column-builders"; +/** + * The SQL types a primary key of `strategy` may be declared as. A `serial` + * reports itself as `serial`, and a hand-written target table may well use a + * plain `integer` key. + */ +const ACCEPTED_KEY_TYPES: Record = { + bigint: ["bigint", "bigserial"], + serial: ["serial", "integer"], + uuid: ["uuid"], +}; + +/** + * Asserts that the column a foreign key points at has the type the key itself + * was built with. Read lazily, like every reference: a relation's target is a + * thunk precisely because it may not exist yet when this table is built. + */ +export const assertContentReferenceType = ({ + column, + contentTypeId, + name, + strategy, +}: { + column: { getSQLType: () => string }; + contentTypeId: string; + name: string; + strategy: ContentIdStrategy; +}): void => { + const actual = column.getSQLType(); + if (ACCEPTED_KEY_TYPES[strategy].includes(actual)) return; + + throw new ContentEngineError( + `Relation field "${name}" targets a content type with \`idStrategy: "${strategy}"\`, so its foreign key is a \`${contentIdSqlType(strategy)}\` - but the referenced column is a \`${actual}\`. Make the target's \`idStrategy\` and its table agree.`, + { contentTypeId }, + ); +}; + +/** + * The strategy a relation's foreign key column is built with. + * + * A table is built while modules are still loading, and a relation's target is + * a thunk precisely so it need not exist yet - so this never insists on reading + * it. A target that resolves (the normal case: `src/database` runs after every + * `src/content` module) gives its own strategy; one that cannot be read yet is + * assumed `serial`, the only strategy that existed before this choice did. The + * lazy check in `checkedReference` then compares the column it actually points + * at with the one that was built, and names the field if they disagree - so a + * wrong guess is a clear error when the foreign key resolves, never a silently + * mistyped column. + */ +export const contentRelationBuildStrategy = ( + owner: { idStrategy: ContentIdStrategy }, + fieldValue: { + self: boolean; + target: () => undefined | { idStrategy: ContentIdStrategy }; + }, +): ContentIdStrategy => { + if (fieldValue.self) return owner.idStrategy; + + try { + return fieldValue.target()?.idStrategy ?? "serial"; + } catch { + return "serial"; + } +}; + const checkedReference = ( contentTypeId: string, name: string, expectedTableName: () => string, thunk: ColumnReferenceThunk, + strategy?: () => ContentIdStrategy, ): ColumnReferenceThunk => { return () => { const column = thunk(); @@ -67,6 +135,15 @@ const checkedReference = ( ); } + if (strategy) { + assertContentReferenceType({ + column, + contentTypeId, + name, + strategy: strategy(), + }); + } + return column; }; }; @@ -76,6 +153,7 @@ const resolveReference = ( name: string, fields: ContentFieldMap, references: Record, + builtStrategy?: ContentIdStrategy, ): ColumnReferenceThunk | undefined => { const fieldValue = fields[name]; @@ -115,6 +193,9 @@ const resolveReference = ( name, () => fieldValue.target().tableName, thunk, + // The strategy the column was *built* with - decided once, below - which + // is what the referenced key has to match. + builtStrategy === undefined ? undefined : () => builtStrategy, ); }; @@ -150,7 +231,7 @@ export const createContentTable = < const referenceThunks = references as Record; const columns: Record = { - ...buildSystemColumns(), + ...buildSystemColumns(definition.idStrategy), ...(definition.publication.enabled ? buildPublicationColumns() : {}), ...(definition.editorial.enabled ? buildEditorialColumns() : {}), ...(definition.visibility.enabled @@ -159,11 +240,25 @@ export const createContentTable = < }; for (const name of Object.keys(fields)) { + const fieldValue = fields[name]; + // Only a to-one relation has a strategy to follow - its target's, decided + // once here and checked against the referenced key when it resolves. + const referenceStrategy = + fieldValue.kind === "relation" + ? contentRelationBuildStrategy(definition, fieldValue) + : undefined; columns[name] = buildContentColumn({ contentTypeId, - fieldValue: fields[name], + fieldValue, name, - reference: resolveReference(contentTypeId, name, fields, referenceThunks), + reference: resolveReference( + contentTypeId, + name, + fields, + referenceThunks, + referenceStrategy, + ), + referenceStrategy, }); } diff --git a/packages/vitnode/src/content/server/translation-editorial-service.test.ts b/packages/vitnode/src/content/server/translation-editorial-service.test.ts index 16951a5aa5..fa031fa41f 100644 --- a/packages/vitnode/src/content/server/translation-editorial-service.test.ts +++ b/packages/vitnode/src/content/server/translation-editorial-service.test.ts @@ -105,7 +105,8 @@ describe("create", () => { expect(outcome.locale).toBe("pl"); expect(revisions.written).toHaveLength(1); expect(revisions.written[0]).toMatchObject({ - itemId: 7, + // The shared revisions table stores the record's key, `contentIdKey(7)`. + itemId: "7", languageId: 2, operation: "create", version: 1, diff --git a/packages/vitnode/src/content/server/translation-editorial-service.ts b/packages/vitnode/src/content/server/translation-editorial-service.ts index 7f22170f2b..879d8ba287 100644 --- a/packages/vitnode/src/content/server/translation-editorial-service.ts +++ b/packages/vitnode/src/content/server/translation-editorial-service.ts @@ -1,5 +1,6 @@ import type { Context } from "hono"; +import type { ContentIdOf } from "../ids"; import type { ContentActor, ContentRevisionMeta, @@ -89,48 +90,48 @@ export interface ContentTranslationEditorialTransitionOptions extends ContentTra export interface ContentTranslationEditorialService { /** Adds a translation and records its `create` revision. Starts as a draft. */ create: ( - itemId: number, + itemId: ContentIdOf, locale: string, values: ContentLocalizedValues, options: ContentTranslationEditorialOptions, ) => Promise>; delete: ( - itemId: number, + itemId: ContentIdOf, locale: string, options: ContentTranslationEditorialWriteOptions, ) => Promise | null>; /** One revision of one locale, with its snapshot. Scoped by both. */ findRevision: ( - itemId: number, + itemId: ContentIdOf, locale: string, revisionId: number, ) => Promise; /** One locale's history, newest first. Metadata only. */ listRevisions: ( - itemId: number, + itemId: ContentIdOf, locale: string, args?: { cursor?: number; limit?: number }, ) => Promise; publish: ( - itemId: number, + itemId: ContentIdOf, locale: string, options: ContentTranslationEditorialTransitionOptions, ) => Promise | null>; restore: ( - itemId: number, + itemId: ContentIdOf, locale: string, revisionId: number, options: ContentTranslationEditorialWriteOptions, ) => Promise | null>; unpublish: ( - itemId: number, + itemId: ContentIdOf, locale: string, options: ContentTranslationEditorialTransitionOptions, ) => Promise | null>; update: ( - itemId: number, + itemId: ContentIdOf, locale: string, values: ContentLocalizedUpdateValues, options: ContentTranslationEditorialWriteOptions, @@ -218,7 +219,7 @@ export const createContentTranslationEditorialService = < }: { after: ContentTranslationRow | null; before: ContentTranslationRow | null; - itemId: number; + itemId: ContentIdOf; languageId: number; locale: string; }, @@ -370,7 +371,7 @@ export const createContentTranslationEditorialService = < /** Publish and unpublish, which differ only in which model method they call. */ const transition = async ( - itemId: number, + itemId: ContentIdOf, locale: string, options: ContentTranslationEditorialTransitionOptions, operation: "publish" | "unpublish", diff --git a/packages/vitnode/src/content/server/translation-http-errors.ts b/packages/vitnode/src/content/server/translation-http-errors.ts index 974fba4421..ceadb82592 100644 --- a/packages/vitnode/src/content/server/translation-http-errors.ts +++ b/packages/vitnode/src/content/server/translation-http-errors.ts @@ -2,6 +2,7 @@ import { HTTPException } from "hono/http-exception"; import { ZodError } from "zod"; import type { ContentTranslationConflict } from "../conflicts"; +import type { ContentId } from "../ids"; import { CONTENT_DELIVERY_CODES, @@ -42,7 +43,7 @@ export const withTranslationHttpErrors = async ( }: { contentTypeId: string; /** Absent on a read, which has no row to attribute a constraint failure to. */ - itemId?: number; + itemId?: ContentId; locale?: string; }, ): Promise => { diff --git a/packages/vitnode/src/content/server/translation-model.ts b/packages/vitnode/src/content/server/translation-model.ts index 5a01c8a227..16248ec42c 100644 --- a/packages/vitnode/src/content/server/translation-model.ts +++ b/packages/vitnode/src/content/server/translation-model.ts @@ -4,6 +4,7 @@ import type { Context } from "hono"; import { and, asc, eq, inArray, ne, sql } from "drizzle-orm"; +import type { ContentId, ContentIdOf } from "../ids"; import type { ContentTranslationSchemas } from "../schemas"; import type { AnyContentTypeDefinition, @@ -81,19 +82,19 @@ export interface ContentTranslationTransitionResult { export interface ContentTranslationModel { create: ( - itemId: number, + itemId: ContentIdOf, locale: string, values: ContentLocalizedValues, options?: ContentTranslationCreateOptions, ) => Promise>; delete: ( - itemId: number, + itemId: ContentIdOf, locale: string, options: ContentTranslationWriteOptions, ) => Promise | null>; exists: ( - itemId: number, + itemId: ContentIdOf, locale: string, options?: ContentTranslationOptions, ) => Promise; @@ -103,7 +104,7 @@ export interface ContentTranslationModel { * has `visibility`, because a hidden record is public in no language. */ findBasePublication: ( - itemId: number, + itemId: ContentIdOf, options?: ContentTranslationOptions, ) => Promise { status: string | undefined; }>; findByLanguageId: ( - itemId: number, + itemId: ContentIdOf, languageId: number, options?: ContentTranslationOptions, ) => Promise | null>; findByLocale: ( - itemId: number, + itemId: ContentIdOf, locale: string, options?: ContentTranslationOptions, ) => Promise | null>; findManyByLanguageId: ( - itemIds: readonly number[], + itemIds: readonly ContentIdOf[], languageId: number, options?: ContentTranslationOptions, ) => Promise[]>; /** Metadata for every translation of one record, without the values. */ findManyForItem: ( - itemId: number, + itemId: ContentIdOf, options?: ContentTranslationOptions, ) => Promise[]>; findManyRowsForItem: ( - itemId: number, + itemId: ContentIdOf, options?: ContentTranslationOptions, ) => Promise[]>; findManyRowsForItems: ( - itemIds: readonly number[], + itemIds: readonly ContentIdOf[], options?: ContentTranslationOptions, ) => Promise[]>; publish: ( - itemId: number, + itemId: ContentIdOf, locale: string, options?: ContentTranslationTransitionOptions, ) => Promise | null>; @@ -158,13 +159,13 @@ export interface ContentTranslationModel { ) => Promise; unpublish: ( - itemId: number, + itemId: ContentIdOf, locale: string, options?: ContentTranslationTransitionOptions, ) => Promise | null>; /** Conditional `UPDATE` guarded by `expectedVersion`. A no-op writes nothing. */ update: ( - itemId: number, + itemId: ContentIdOf, locale: string, values: ContentLocalizedUpdateValues, options: ContentTranslationWriteOptions, @@ -284,7 +285,7 @@ export const createContentTranslationModel = < ({ ...publicationOf(row), createdAt: row.createdAt as Date, - itemId: row.itemId as number, + itemId: row.itemId as ContentId, languageId: row.languageId as number, locale, updatedAt: row.updatedAt as Date, @@ -310,7 +311,7 @@ export const createContentTranslationModel = < typeof row.version === "number" ? row.version : 1; const readOne = async ( - itemId: number, + itemId: ContentId, languageId: number, database: ContentDatabase, ): Promise> => { @@ -335,7 +336,7 @@ export const createContentTranslationModel = < }; const transition = async ( - itemId: number, + itemId: ContentId, locale: string, options: ContentTranslationTransitionOptions, { @@ -403,7 +404,7 @@ export const createContentTranslationModel = < }; const assertItemExists = async ( - itemId: number, + itemId: ContentId, database: ContentDatabase, ): Promise => { const [row] = await database diff --git a/packages/vitnode/src/content/server/translation-routes.ts b/packages/vitnode/src/content/server/translation-routes.ts index 1c4fe1d515..2e887d766a 100644 --- a/packages/vitnode/src/content/server/translation-routes.ts +++ b/packages/vitnode/src/content/server/translation-routes.ts @@ -38,7 +38,7 @@ import { createContentPreviewToken } from "./preview-token"; import { contentPublicLocaleStates } from "./public-locales"; import { CONTENT_REVISIONS_MAX_PAGE_SIZE } from "./revisions-model"; import { - identifier, + contentIdentifier, jsonBody, jsonResponse, plainOutcome, @@ -166,7 +166,9 @@ export const buildContentTranslationRoutes = < // per language to open one record. Additive to the metadata this route has // always returned, so a client that only reads `version` and `status` is // unaffected. - const edges = await translations(c).findManyRowsForItem(identifier(c)); + const edges = await translations(c).findManyRowsForItem( + contentIdentifier(c, definition), + ); return c.json({ edges }, 200); }, @@ -187,7 +189,10 @@ export const buildContentTranslationRoutes = < }, }, handler: async c => { - const row = await translations(c).findByLocale(identifier(c), locale(c)); + const row = await translations(c).findByLocale( + contentIdentifier(c, definition), + locale(c), + ); if (!row) { throw new HTTPException(404, { message: "Translation not found." }); } @@ -215,7 +220,7 @@ export const buildContentTranslationRoutes = < }, }, handler: async c => { - const id = identifier(c); + const id = contentIdentifier(c, definition); const target = locale(c); const { values } = await readJson(c, translationSchemas.createEnvelope); @@ -270,7 +275,7 @@ export const buildContentTranslationRoutes = < }, handler: async c => { const savedFrom = new Date(); - const id = identifier(c); + const id = contentIdentifier(c, definition); const target = locale(c); const { expectedVersion, values } = await readJson( c, @@ -349,7 +354,7 @@ export const buildContentTranslationRoutes = < }, }, handler: async c => { - const id = identifier(c); + const id = contentIdentifier(c, definition); const target = locale(c); const { expectedVersion } = await readJson( c, @@ -425,7 +430,7 @@ export const buildContentTranslationRoutes = < }, }, handler: async c => { - const id = identifier(c); + const id = contentIdentifier(c, definition); const target = locale(c); const { expectedVersion } = await readJson( c, @@ -489,7 +494,7 @@ export const buildContentTranslationRoutes = < }); const revisionParams = z.object({ - id: z.coerce.number(), + id: model.schemas.params.shape.id, locale: z.string().min(1).max(CONTENT_LOCALE_MAX_LENGTH), revisionId: z.coerce.number(), }); @@ -545,10 +550,14 @@ export const buildContentTranslationRoutes = < const page = await withTranslationHttpErrors( "read", async () => - await editorial(c).listRevisions(identifier(c), locale(c), { - cursor, - limit: first, - }), + await editorial(c).listRevisions( + contentIdentifier(c, definition), + locale(c), + { + cursor, + limit: first, + }, + ), { contentTypeId: definition.id }, ); @@ -575,7 +584,7 @@ export const buildContentTranslationRoutes = < "read", async () => await editorial(c).findRevision( - identifier(c), + contentIdentifier(c, definition), locale(c), revisionIdentifier(c), ), @@ -618,7 +627,7 @@ export const buildContentTranslationRoutes = < }, }, handler: async c => { - const id = identifier(c); + const id = contentIdentifier(c, definition); const target = locale(c); const revisionId = revisionIdentifier(c); const { expectedVersion } = await readJson( @@ -705,7 +714,7 @@ export const buildContentTranslationRoutes = < // record that exists and one that does not. assertContentPreviewIsServable(); - const id = identifier(c); + const id = contentIdentifier(c, definition); const target = locale(c); const translation = await withTranslationHttpErrors( @@ -806,7 +815,13 @@ export const buildContentTranslationRoutes = < }, handler: async c => c.json( - { edges: await contentPublicLocaleStates(c, model, identifier(c)) }, + { + edges: await contentPublicLocaleStates( + c, + model, + contentIdentifier(c, definition), + ), + }, 200, ), }); diff --git a/packages/vitnode/src/content/server/translation-table.ts b/packages/vitnode/src/content/server/translation-table.ts index 9aba9ae03c..54c4f1fe2b 100644 --- a/packages/vitnode/src/content/server/translation-table.ts +++ b/packages/vitnode/src/content/server/translation-table.ts @@ -52,6 +52,7 @@ export const createContentTranslationTable = < const columns: Record = { ...buildTranslationSystemColumns({ itemReference: () => baseColumns.id, + itemStrategy: definition.idStrategy, languageReference: () => core_languages.id, }), // Only with publication, matching the base table exactly. Without a global diff --git a/packages/vitnode/src/content/server/types.ts b/packages/vitnode/src/content/server/types.ts index 0f3c9658bc..7b9e41e962 100644 --- a/packages/vitnode/src/content/server/types.ts +++ b/packages/vitnode/src/content/server/types.ts @@ -1,6 +1,8 @@ import type { $Type } from "drizzle-orm"; import type { AnyPgColumnBuilder, + HasIdentity, + PgBigIntStringBuilder, PgBooleanBuilder, PgBuildColumns, PgDoublePrecisionBuilder, @@ -10,12 +12,14 @@ import type { PgTableWithColumns, PgTextBuilder, PgTimestampBuilder, + PgUUIDBuilder, PgVarcharBuilder, SetHasDefault, SetNotNull, } from "drizzle-orm/pg-core"; import type { RichTextDocument } from "../rich-text/document"; +import type { ContentIdStrategy } from "../ids"; import type { ContentEditorialField, ContentFieldsOf, @@ -38,20 +42,45 @@ type EnumValuesOf = TField extends { ? [THead, ...TRest] : [string, ...string[]]; -type BaseBuilderFor = TField extends { kind: "boolean" } - ? PgBooleanBuilder - : TField extends { kind: "dateTime" } - ? PgTimestampBuilder - : TField extends { kind: "enum" } - ? PgVarcharBuilder> - : TField extends { integer: false; kind: "number" } - ? PgDoublePrecisionBuilder - : TField extends { kind: "number" | "relation" | "user" } - ? PgIntegerBuilder - : TField extends { kind: "textarea" } - ? PgTextBuilder - : TField extends { kind: "richText" } - ? $Type +/** The primary key column of a content table, per id strategy. */ +export type ContentIdPkBuilder = + TStrategy extends "uuid" + ? SetHasDefault + : TStrategy extends "bigint" + ? HasIdentity + : PgSerialBuilder; + +/** A foreign key column pointing at a content table with `TStrategy` ids. */ +export type ContentIdFkBuilder = + TStrategy extends "uuid" + ? PgUUIDBuilder + : TStrategy extends "bigint" + ? PgBigIntStringBuilder + : PgIntegerBuilder; + +/** The strategy a relation descriptor's target thunk carries. */ +type RelationTargetStrategy = TField extends { + target: () => { idStrategy: infer TStrategy extends ContentIdStrategy }; +} + ? TStrategy + : "serial"; + +type BaseBuilderFor = TField extends { kind: "relation" } + ? ContentIdFkBuilder> + : TField extends { kind: "boolean" } + ? PgBooleanBuilder + : TField extends { kind: "dateTime" } + ? PgTimestampBuilder + : TField extends { kind: "enum" } + ? PgVarcharBuilder> + : TField extends { integer: false; kind: "number" } + ? PgDoublePrecisionBuilder + : TField extends { kind: "number" | "user" } + ? PgIntegerBuilder + : TField extends { kind: "textarea" } + ? PgTextBuilder + : TField extends { kind: "richText" } + ? $Type : PgVarcharBuilder; type ApplyDefault = @@ -72,9 +101,11 @@ export type ContentColumnBuilder = : never; /** `id`, `createdAt` and `updatedAt` - added to every content table. */ -export interface ContentSystemColumnBuilders { +export interface ContentSystemColumnBuilders< + TStrategy extends ContentIdStrategy = "serial", +> { createdAt: SetNotNull>; - id: PgSerialBuilder; + id: ContentIdPkBuilder; updatedAt: SetNotNull>; } @@ -115,7 +146,8 @@ export type ContentColumnBuilders< TPublication extends boolean = false, TEditorial extends boolean = false, TVisibility extends boolean = false, -> = ContentSystemColumnBuilders & + TStrategy extends ContentIdStrategy = "serial", +> = ContentSystemColumnBuilders & EditorialColumnBuilders & PublicationColumnBuilders & VisibilityColumnBuilders & { @@ -123,9 +155,11 @@ export type ContentColumnBuilders< }; /** `itemId`, `languageId`, `version` and the timestamps. */ -export interface ContentTranslationSystemColumnBuilders { +export interface ContentTranslationSystemColumnBuilders< + TStrategy extends ContentIdStrategy = "serial", +> { createdAt: SetNotNull>; - itemId: SetNotNull; + itemId: SetNotNull>; languageId: SetNotNull; updatedAt: SetNotNull>; version: SetNotNull>; @@ -134,7 +168,8 @@ export interface ContentTranslationSystemColumnBuilders { export type ContentTranslationColumnBuilders< TFields, TPublication extends boolean = false, -> = ContentTranslationSystemColumnBuilders & + TStrategy extends ContentIdStrategy = "serial", +> = ContentTranslationSystemColumnBuilders & PublicationColumnBuilders & { [K in keyof TFields]: ContentColumnBuilder; }; @@ -143,10 +178,11 @@ export type ContentTranslationTable< TName extends string, TFields, TPublication extends boolean = false, + TStrategy extends ContentIdStrategy = "serial", > = PgTableWithColumns<{ columns: PgBuildColumns< TName, - ContentTranslationColumnBuilders + ContentTranslationColumnBuilders >; dialect: "pg"; name: TName; @@ -166,10 +202,18 @@ export type ContentTranslationTableFor = TDefinition extends { ? ContentTranslationTable< string, LocalizedFieldsOf, - TPublication + TPublication, + ContentIdStrategyOf > : never; +/** A definition's id strategy, or `serial` for a shape that predates it. */ +export type ContentIdStrategyOf = TDefinition extends { + idStrategy: infer TStrategy extends ContentIdStrategy; +} + ? TStrategy + : "serial"; + export type ContentTranslationColumnName = | ContentLocalizedFieldName | ContentTranslationSystemField @@ -183,10 +227,17 @@ export type ContentTable< TPublication extends boolean = false, TEditorial extends boolean = false, TVisibility extends boolean = false, + TStrategy extends ContentIdStrategy = "serial", > = PgTableWithColumns<{ columns: PgBuildColumns< TName, - ContentColumnBuilders + ContentColumnBuilders< + TFields, + TPublication, + TEditorial, + TVisibility, + TStrategy + > >; dialect: "pg"; name: TName; @@ -268,7 +319,8 @@ export type ContentTableFor = TDefinition extends { // Read on its own rather than inferred beside the other two: a definition // written before `visibility` existed has no such key, and it must keep // resolving to the table it always had. - TDefinition extends { visibility: { enabled: true } } ? true : false + TDefinition extends { visibility: { enabled: true } } ? true : false, + ContentIdStrategyOf > : never; @@ -330,14 +382,17 @@ export type ContentReferences = { * and clamped with a fingerprint, and re-deriving that clamp in the type system * would be a second implementation of it. */ -export type ContentJunctionTable = PgTableWithColumns<{ +export type ContentJunctionTable< + TOwner extends ContentIdStrategy = "serial", + TRelated extends ContentIdStrategy = "serial", +> = PgTableWithColumns<{ columns: PgBuildColumns< string, { createdAt: SetNotNull>; - itemId: SetNotNull; + itemId: SetNotNull>; position: SetNotNull; - relatedItemId: SetNotNull; + relatedItemId: SetNotNull>; } >; dialect: "pg"; @@ -345,8 +400,15 @@ export type ContentJunctionTable = PgTableWithColumns<{ schema: undefined; }>; -/** The generated child table for one repeatable field. */ -export type ContentRepeatableChildTable = PgTableWithColumns<{ +/** + * The generated child table for one repeatable field. A child row's own `id` + * stays `serial` whatever the owner's strategy: it is an implementation detail + * of the collection, never addressed from outside the record. + */ +export type ContentRepeatableChildTable< + TFields, + TOwner extends ContentIdStrategy = "serial", +> = PgTableWithColumns<{ columns: PgBuildColumns< string, { @@ -354,7 +416,7 @@ export type ContentRepeatableChildTable = PgTableWithColumns<{ } & { createdAt: SetNotNull>; id: PgSerialBuilder; - itemId: SetNotNull; + itemId: SetNotNull>; position: SetNotNull; updatedAt: SetNotNull>; } @@ -364,16 +426,29 @@ export type ContentRepeatableChildTable = PgTableWithColumns<{ schema: undefined; }>; -/** Every generated collection table of one content type, by field name. */ +/** + * Every generated collection table of one content type, by field name. Erased + * over the strategies: the store reads them by name and converts identifiers + * itself, per the owner's and each target's strategy. + */ export interface ContentAdvancedTables { - junctions: Record; - repeatables: Record>; + junctions: Record< + string, + ContentJunctionTable + >; + repeatables: Record< + string, + ContentRepeatableChildTable + >; } // Loosest shape a foreign key target can take; the FK itself is validated by -// Postgres, and by `getTableConfig` in the table tests. -type AnyIdColumn = Parameters< - PgIntegerBuilder["references"] ->[0] extends () => infer TColumn +// Postgres, by the strategy check in `createContentTable`, and by +// `getTableConfig` in the table tests. +type AnyIdColumn = ReferenceTargetOf; + +type ReferenceTargetOf< + TBuilder extends { references: (...args: never[]) => unknown }, +> = Parameters[0] extends () => infer TColumn ? TColumn : never; diff --git a/packages/vitnode/src/content/server/visibility-effects.ts b/packages/vitnode/src/content/server/visibility-effects.ts index d81c7d0d91..2f21f04be2 100644 --- a/packages/vitnode/src/content/server/visibility-effects.ts +++ b/packages/vitnode/src/content/server/visibility-effects.ts @@ -14,6 +14,7 @@ import { contentLocaleInvalidations, diffContentPublicLocaleStates, } from "../cache"; +import { requireContentId } from "../ids"; import { contentEditorialEffects } from "./editorial-effects"; import { contentDefinitionOf } from "./model"; import { contentPublicLocaleStates } from "./public-locales"; @@ -50,7 +51,7 @@ export const contentVisibilityRevalidation = async ( ): Promise => { const definition: AnyContentTypeDefinition = contentDefinitionOf(model); const { isPublic, wasPublic } = visibility; - const id = typeof row.id === "number" ? row.id : 0; + const id = requireContentId(definition.idStrategy, row.id, definition.id); const slug = definition.publicApi.enabled ? row[definition.publicApi.slugField] : undefined; diff --git a/packages/vitnode/src/content/sitemap.ts b/packages/vitnode/src/content/sitemap.ts index b9bd829b4c..342b621099 100644 --- a/packages/vitnode/src/content/sitemap.ts +++ b/packages/vitnode/src/content/sitemap.ts @@ -1,15 +1,18 @@ +import type { ContentId } from "./ids"; import type { ContentSitemapChangeFrequency } from "./types"; import { CONTENT_SITEMAP_DEFAULT_PAGE_SIZE, CONTENT_SITEMAP_MAX_URLS, } from "./const"; +import { contentIdKey } from "./ids"; /** One line of a sitemap, as the delivery service produces it. */ export interface ContentSitemapEntry { /** One of the seven `changefreq` values, or `null` to omit the element. */ changeFrequency: ContentSitemapChangeFrequency | null; - itemId: number; + /** The record's id under its content type's strategy. */ + itemId: ContentId; lastModified: Date; /** The language this URL is in, or `null` for a nonlocalized content type. */ @@ -44,11 +47,12 @@ export const contentSitemapXml = ({ origin, }: { /** - * The alternates of each entry, keyed by `itemId`. Omit it and no `xhtml:link` + * The alternates of each entry, keyed by `itemId` - or by its `contentIdKey`, + * as `readDeliveryAlternatesMany` returns them. Omit it and no `xhtml:link` * element is emitted at all, which is a valid sitemap and the right default. */ alternates?: ReadonlyMap< - number, + ContentId, readonly { locale: string; origin?: string; path: string }[] >; entries: readonly ContentSitemapEntry[]; @@ -78,7 +82,9 @@ export const contentSitemapXml = ({ lines.push(` ${formatPriority(entry.priority)}`); } - for (const alternate of alternates?.get(entry.itemId) ?? []) { + for (const alternate of alternates?.get(entry.itemId) ?? + alternates?.get(contentIdKey(entry.itemId)) ?? + []) { const href = absolute(alternate.origin ?? origin, alternate.path); if (href === null) continue; diff --git a/packages/vitnode/src/content/types.ts b/packages/vitnode/src/content/types.ts index 08d8ae49ff..53cfcc338f 100644 --- a/packages/vitnode/src/content/types.ts +++ b/packages/vitnode/src/content/types.ts @@ -20,7 +20,12 @@ import type { CONTENT_VISIBILITY_FILTERS, } from "./const"; import type { ContentFileDescriptor } from "./files"; -import type { ContentIdStrategy } from "./ids"; +import type { + ContentId, + ContentIdOf, + ContentIdOfStrategy, + ContentIdStrategy, +} from "./ids"; import type { RichTextDocument } from "./rich-text/document"; import type { ContentSchemas } from "./schemas"; @@ -231,12 +236,24 @@ interface ContentReferenceCollection { multiple: true; } +/** + * What a relation thunk resolves to: any content type, narrowed to the id + * strategy its records are keyed by. The strategy is the only thing about the + * target a relation's *value* depends on - it decides whether the foreign key is + * an `integer`, a `uuid` or a `bigint`, and whether the API carries a number or + * a string. + */ +export type ContentRelationTarget< + TStrategy extends ContentIdStrategy = ContentIdStrategy, +> = AnyContentTypeDefinition & { idStrategy: TStrategy }; + export interface ContentRelationField< TRequired extends boolean = boolean, TNullable extends boolean = boolean, TMultiple extends boolean = boolean, TOrdered extends boolean = boolean, TSelf extends boolean = boolean, + TTargetStrategy extends ContentIdStrategy = ContentIdStrategy, > extends ContentFieldShared { kind: "relation"; @@ -248,8 +265,11 @@ export interface ContentRelationField< ordered: TOrdered; self: TSelf; - /** Thunk so two content types can refer to each other. */ - target: () => AnyContentTypeDefinition; + /** + * Thunk so two content types can refer to each other. Its return type carries + * the target's id strategy, which is what the relation's values are typed by. + */ + target: () => ContentRelationTarget; } export interface ContentGroupField< @@ -340,6 +360,25 @@ type ApplyNullable = TField extends { nullable: true } ? null | TValue : TValue; +/** + * The identifier type a relation field holds: its target's, read off the + * target thunk. A field whose target is erased holds either, which is what + * generic engine code handles. + */ +export type ContentRelationIdOf = TField extends { + target: () => { idStrategy: infer TStrategy }; +} + ? ContentIdOfStrategy + : number; + +/** + * The identifier one reference field holds: a relation's target id, or the + * `core_users.id` / `core_files.id` a user or file field holds. + */ +export type ContentReferenceIdOf = TField extends { kind: "relation" } + ? ContentRelationIdOf + : number; + /** The scalar half of {@link ContentFieldValue}, before nullability. */ type ScalarFieldValue = TField extends { kind: "boolean" } ? boolean @@ -347,10 +386,12 @@ type ScalarFieldValue = TField extends { kind: "boolean" } ? Date : TField extends { values: readonly (infer TValue)[] } ? TValue - : TField extends { kind: "file" | "number" | "relation" | "user" } - ? number - : TField extends { kind: "richText" } - ? RichTextDocument + : TField extends { kind: "relation" } + ? ContentRelationIdOf + : TField extends { kind: "file" | "number" | "user" } + ? number + : TField extends { kind: "richText" } + ? RichTextDocument : string; /** The scalar half of {@link ContentFieldInput}. `dateTime` crosses as ISO. */ @@ -360,10 +401,12 @@ type ScalarFieldInput = TField extends { kind: "boolean" } ? string : TField extends { values: readonly (infer TValue)[] } ? TValue - : TField extends { kind: "file" | "number" | "relation" | "user" } - ? number - : TField extends { kind: "richText" } - ? RichTextDocument + : TField extends { kind: "relation" } + ? ContentRelationIdOf + : TField extends { kind: "file" | "number" | "user" } + ? number + : TField extends { kind: "richText" } + ? RichTextDocument : string; /** Every leaf of a group, as it comes back. Nested, never flattened. */ @@ -402,7 +445,7 @@ export type ContentFieldValue = TField extends { kind: "blocks" } : TField extends { fields: infer TInner; kind: "repeatable" } ? ContentRepeatableRow[] : TField extends ContentReferenceCollection - ? number[] + ? ContentReferenceIdOf[] : ApplyNullable, TField>; export type ContentFieldInput = TField extends { kind: "blocks" } @@ -415,7 +458,7 @@ export type ContentFieldInput = TField extends { kind: "blocks" } : TField extends { fields: infer TInner; kind: "repeatable" } ? ContentRepeatableInputRow[] : TField extends ContentReferenceCollection - ? number[] + ? ContentReferenceIdOf[] : ApplyNullable, TField>; export type ContentFieldPatch = TField extends { @@ -1615,7 +1658,7 @@ export type ContentTranslationPublicationColumns = export type ContentTranslationRow = Prettify< ContentTranslationPublicationColumns & { createdAt: Date; - itemId: number; + itemId: ContentIdOf; languageId: number; /** The canonical `core_languages.code`, never the caller's casing. */ locale: string; @@ -1629,7 +1672,7 @@ export type ContentTranslationMeta = Prettify< ContentTranslationPublicationColumns & { createdAt: Date; - itemId: number; + itemId: ContentIdOf; languageId: number; locale: string; updatedAt: Date; @@ -1787,12 +1830,51 @@ export interface ContentTypeDefinition< /** Use in constraints where the concrete field map does not matter. */ export type AnyContentTypeDefinition = ContentTypeDefinition; +/** + * A definition's field map, with every `self: true` relation re-pointed at the + * definition's own id strategy. `field.relation({ self: true })` is written + * before the content type exists, so its descriptor cannot know which strategy + * it will be keyed by; this is the one place that fills it in. A `serial` + * content type - every content type written before strategies existed - and an + * erased field map are returned untouched. + */ export type ContentFieldsOf = TDefinition extends { fields: infer TFields; } - ? TFields + ? TDefinition extends { idStrategy: infer TStrategy } + ? [TStrategy] extends ["serial"] + ? TFields + : string extends keyof TFields + ? TFields + : TStrategy extends ContentIdStrategy + ? ContentBindSelfRelations + : TFields + : TFields : never; +/** {@link ContentFieldsOf}'s rebinding of self relations, for one field map. */ +export type ContentBindSelfRelations< + TFields, + TStrategy extends ContentIdStrategy, +> = { + [K in keyof TFields]: TFields[K] extends ContentRelationField< + infer TRequired, + infer TNullable, + infer TMultiple, + infer TOrdered, + true + > + ? ContentRelationField< + TRequired, + TNullable, + TMultiple, + TOrdered, + true, + TStrategy + > + : TFields[K]; +}; + export type ContentSelect = Prettify< ContentEditorialColumns & ContentPublicationColumns & @@ -1800,7 +1882,7 @@ export type ContentSelect = Prettify< [K in ColumnFieldKeys>]: ContentFieldValue< ContentFieldsOf[K] >; - } & { createdAt: Date; id: number; updatedAt: Date } + } & { createdAt: Date; id: ContentIdOf; updatedAt: Date } >; export type ContentDetail = Prettify< @@ -1858,12 +1940,23 @@ export type PublicFilterableContentFieldName = AnyFieldNamesOfKind< FilterableContentFieldKind >; -export interface ContentRelationFilter { - contains: number; +/** A to-many filter by membership: rows whose collection holds `contains`. */ +export interface ContentRelationFilter { + contains: TId; } +/** One {@link ContentRelationFilter} per to-many reference, typed by its target. */ +type ContentCollectionFilters = { + [K in keyof ContentFieldsOf & TName]: ContentRelationFilter< + ContentReferenceIdOf[K]> + >; +}; + export type ContentFilterInput = Partial< - Record, ContentRelationFilter> & + ContentCollectionFilters< + TDefinition, + ContentRelationCollectionName + > & (TDefinition extends { publication: { enabled: true } } ? { status: ContentPublicationStatus } : Record) & @@ -1896,8 +1989,9 @@ export type ContentReferenceFieldName = FieldNamesOfKind< // Public projection // --------------------------------------------------------------------------- -export interface ContentPublicRelation { - id: number; +/** A to-one relation as the public API returns it. */ +export interface ContentPublicRelation { + id: TId; } /** The exposed field names of one content type, read off its resolved config. */ @@ -1907,8 +2001,8 @@ export type ContentPublicFieldName = TDefinition extends { ? TField : never; -type ContentPublicValue = TName extends "id" - ? number +type ContentPublicValue = TName extends "id" + ? TId : TName extends "createdAt" | "updatedAt" ? Date : TName extends "publishedAt" @@ -1927,11 +2021,13 @@ type ContentPublicValue = TName extends "id" ? ContentFileDescriptor | null : ContentFileDescriptor : TFields[TName] extends { kind: "relation"; multiple: true } - ? number[] + ? ContentRelationIdOf[] : TFields[TName] extends { kind: "relation" } ? TFields[TName] extends { nullable: true } - ? ContentPublicRelation | null - : ContentPublicRelation + ? ContentPublicRelation< + ContentRelationIdOf + > | null + : ContentPublicRelation> : ContentFieldValue : never; @@ -1997,7 +2093,11 @@ export type ContentPublicSelect = Prettify< > & { [ K in PublicFlatName> - ]: ContentPublicValue, K>; + ]: ContentPublicValue< + ContentFieldsOf, + K, + ContentIdOf + >; } >; @@ -2039,10 +2139,10 @@ export type ContentPublicListRow = * narrower check is the runtime allowlist. */ export type ContentPublicFilterInput = Partial< - Record< + ContentCollectionFilters< + TDefinition, ContentPublicFieldName & - ContentRelationCollectionName, - ContentRelationFilter + ContentRelationCollectionName > & { [ K in ContentPublicFieldName & diff --git a/packages/vitnode/src/database/content.ts b/packages/vitnode/src/database/content.ts index 42ca83eeeb..d4f7654eda 100644 --- a/packages/vitnode/src/database/content.ts +++ b/packages/vitnode/src/database/content.ts @@ -11,6 +11,7 @@ import type { import { CONTENT_ACTOR_TYPES, CONTENT_DELIVERY_PATH_MAX_LENGTH, + CONTENT_ID_KEY_LENGTH, CONTENT_REVISION_OPERATIONS, CONTENT_SCHEDULE_ACTIONS, CONTENT_SCHEDULE_STATUSES, @@ -25,7 +26,7 @@ export const core_content_revisions = camelCase.table.withRLS( id: t.serial().primaryKey(), pluginId: t.varchar({ length: 255 }).notNull(), contentTypeId: t.varchar({ length: 100 }).notNull(), - itemId: t.integer().notNull(), + itemId: t.varchar({ length: CONTENT_ID_KEY_LENGTH }).notNull(), languageId: t.integer(), /** The version the record holds *after* this mutation. */ @@ -137,7 +138,7 @@ export const core_content_schedules = camelCase.table.withRLS( id: t.serial().primaryKey(), pluginId: t.varchar({ length: 255 }).notNull(), contentTypeId: t.varchar({ length: 100 }).notNull(), - itemId: t.integer().notNull(), + itemId: t.varchar({ length: CONTENT_ID_KEY_LENGTH }).notNull(), action: t.varchar({ enum: CONTENT_SCHEDULE_ACTIONS, length: 16 }).notNull(), scheduledFor: t.timestamp().notNull(), @@ -187,7 +188,7 @@ export const core_content_slug_history = camelCase.table.withRLS( id: t.serial().primaryKey(), pluginId: t.varchar({ length: 255 }).notNull(), contentTypeId: t.varchar({ length: 100 }).notNull(), - itemId: t.integer().notNull(), + itemId: t.varchar({ length: CONTENT_ID_KEY_LENGTH }).notNull(), /** `NULL` for a shared slug. See the table comment. */ languageId: t.integer(), slug: t.varchar({ length: CONTENT_SLUG_DEFAULT_LENGTH }).notNull(), diff --git a/packages/vitnode/src/database/search.ts b/packages/vitnode/src/database/search.ts index c102c567d5..529caecd11 100644 --- a/packages/vitnode/src/database/search.ts +++ b/packages/vitnode/src/database/search.ts @@ -3,6 +3,8 @@ import type { SQL } from "drizzle-orm"; import { sql } from "drizzle-orm"; import { camelCase, customType, index, unique } from "drizzle-orm/pg-core"; +import { CONTENT_ID_KEY_LENGTH } from "../content/const"; + // Drizzle ships no native `tsvector` type, so we declare it once and reuse it // for the generated full-text-search column below. The column and its GIN index // live in the schema (not a hand-written migration) so `drizzle-kit generate` @@ -68,7 +70,7 @@ export const core_search_index = camelCase.table.withRLS( id: t.serial().primaryKey(), pluginId: t.varchar({ length: 255 }).notNull(), itemType: t.varchar({ length: 100 }).notNull(), - itemId: t.integer().notNull(), + itemId: t.varchar({ length: CONTENT_ID_KEY_LENGTH }).notNull(), languageCode: t.varchar({ length: 32 }).notNull().default(""), authorIds: t .integer() diff --git a/packages/vitnode/src/lib/plugin.ts b/packages/vitnode/src/lib/plugin.ts index 452125f796..ec75372116 100644 --- a/packages/vitnode/src/lib/plugin.ts +++ b/packages/vitnode/src/lib/plugin.ts @@ -67,8 +67,11 @@ export interface ContentCellProps< export interface ContentFormLayoutProps { contentTypeId: string; - /** `undefined` while creating - the record does not exist yet. */ - itemId?: number; + /** + * `undefined` while creating - the record does not exist yet. A number for a + * `serial` content type, the canonical string for a `uuid` or `bigint` one. + */ + itemId?: number | string; mode: "create" | "edit"; pluginId: string; /** Whether the content type has the draft/published lifecycle. */ diff --git a/packages/vitnode/src/tanstack/admin/content/bulk-actions.tsx b/packages/vitnode/src/tanstack/admin/content/bulk-actions.tsx index f7d946cedd..43454ef52b 100644 --- a/packages/vitnode/src/tanstack/admin/content/bulk-actions.tsx +++ b/packages/vitnode/src/tanstack/admin/content/bulk-actions.tsx @@ -4,6 +4,7 @@ import React from "react"; import { toast } from "sonner"; import { useTranslations } from "use-intl"; +import type { ContentId } from "@/content/ids"; import type { RegisteredFrontendContentType } from "@/content/index"; import type { ContentLabels } from "@/views/admin/views/content/content-labels"; import type { @@ -53,14 +54,14 @@ const ContentBulkActionButton = ({ const tErrors = useTranslations("core.global.errors"); const queryClient = useQueryClient(); const { clear, selected, toggle } = useDataTableSelection(); - const settledRef = React.useRef(null); + const settledRef = React.useRef(null); const rowsById = new Map(rows.map(row => [row.id, row])); const nameFor = (count: number) => count === 1 ? labels.singular : labels.plural; const target = contentApiTarget(definition, pluginId); - const runOne = async (id: number) => { + const runOne = async (id: ContentId) => { if (action !== "delete") { return await setContentPublicationInBrowser({ action, id, target }); } @@ -75,7 +76,7 @@ const ContentBulkActionButton = ({ }); }; - const titlesOf = (ids: readonly number[]): string => { + const titlesOf = (ids: readonly ContentId[]): string => { const titles = ids .slice(0, TITLES_IN_TOAST) .map(id => rowsById.get(id)) diff --git a/packages/vitnode/src/tanstack/admin/content/form/page-body.tsx b/packages/vitnode/src/tanstack/admin/content/form/page-body.tsx index dea0682cfc..1538915398 100644 --- a/packages/vitnode/src/tanstack/admin/content/form/page-body.tsx +++ b/packages/vitnode/src/tanstack/admin/content/form/page-body.tsx @@ -1,6 +1,7 @@ import { useSuspenseQuery } from "@tanstack/react-query"; import { useTranslations } from "use-intl"; +import type { ContentId } from "@/content/ids"; import type { ContentAdminAction, RegisteredFrontendContentType, @@ -78,7 +79,7 @@ const ContentEditScreen = ({ title, }: { entry: RegisteredFrontendContentType; - itemId: number; + itemId: ContentId; title: string; }) => { const t = useTranslations("core.content.edit"); @@ -132,11 +133,12 @@ export const ContentFormPageBody = ({ }: { action: Exclude; entry: RegisteredFrontendContentType; - itemId: number; + /** Present exactly when `action` is `edit` - the route resolver guarantees it. */ + itemId?: ContentId; title: string; }) => action === "create" ? ( - ) : ( + ) : itemId === undefined ? null : ( ); diff --git a/packages/vitnode/src/tanstack/admin/content/form/screen.tsx b/packages/vitnode/src/tanstack/admin/content/form/screen.tsx index 83037af90d..8de424627a 100644 --- a/packages/vitnode/src/tanstack/admin/content/form/screen.tsx +++ b/packages/vitnode/src/tanstack/admin/content/form/screen.tsx @@ -53,7 +53,7 @@ export const ContentFormScreen = ({ diff --git a/packages/vitnode/src/tanstack/admin/content/form/transport.ts b/packages/vitnode/src/tanstack/admin/content/form/transport.ts index 06a45cc8ae..04e58ede0c 100644 --- a/packages/vitnode/src/tanstack/admin/content/form/transport.ts +++ b/packages/vitnode/src/tanstack/admin/content/form/transport.ts @@ -1,5 +1,6 @@ import type { QueryClient } from "@tanstack/react-query"; +import type { ContentId } from "@/content/ids"; import type { ContentApiTarget } from "@/views/admin/views/content/content-request"; import type { ContentFormTransport } from "@/views/admin/views/content/form/transport"; @@ -48,7 +49,7 @@ export const contentFormTransport = ( queryClient: QueryClient, ): ContentFormTransport => { /** What a successful write to one record owes the rest of the AdminCP. */ - const settled = async (contentTypeId: string, itemId?: number) => { + const settled = async (contentTypeId: string, itemId?: ContentId) => { await invalidateContentAfterWrite(queryClient, { contentTypeId, ...(itemId === undefined ? {} : { itemId }), diff --git a/packages/vitnode/src/tanstack/admin/content/query.ts b/packages/vitnode/src/tanstack/admin/content/query.ts index f95e695f1b..9e818f76dd 100644 --- a/packages/vitnode/src/tanstack/admin/content/query.ts +++ b/packages/vitnode/src/tanstack/admin/content/query.ts @@ -85,7 +85,7 @@ export const invalidateContentAfterWrite = async ( contentTypeId, itemId, removed = false, - }: { contentTypeId: string; itemId?: number; removed?: boolean }, + }: { contentTypeId: string; itemId?: ContentId; removed?: boolean }, ): Promise => { removeContentOptions(queryClient, contentTypeId); @@ -106,7 +106,7 @@ export const invalidateContentAfterBulkWrite = async ( contentTypeId, itemIds, removed, - }: { contentTypeId: string; itemIds: readonly number[]; removed: boolean }, + }: { contentTypeId: string; itemIds: readonly ContentId[]; removed: boolean }, ): Promise => { removeContentOptions(queryClient, contentTypeId); diff --git a/packages/vitnode/src/tanstack/admin/content/route.tsx b/packages/vitnode/src/tanstack/admin/content/route.tsx index 780c365c0c..78b3cd45c1 100644 --- a/packages/vitnode/src/tanstack/admin/content/route.tsx +++ b/packages/vitnode/src/tanstack/admin/content/route.tsx @@ -2,6 +2,7 @@ import { notFound } from "@tanstack/react-router"; import { createTranslator } from "use-intl"; import type { ContentLabelTranslator } from "@/content/admin/labels"; +import type { ContentId } from "@/content/ids"; import type { ContentAdminAction, ContentFrontendRegistry, @@ -36,7 +37,7 @@ export interface ContentAdminScreen { action: ContentAdminAction; entry: RegisteredFrontendContentType; /** The record being edited. Only ever set for `edit`. */ - itemId?: number; + itemId?: ContentId; } export const resolveContentAdminScreen = ( @@ -67,7 +68,7 @@ export interface ContentAdminRouteData { adminPath: string; contentTypeId: string; description: string | undefined; - itemId?: number; + itemId?: ContentId; labels: ContentRouteLabels; diff --git a/packages/vitnode/src/tanstack/admin/content/slots.ts b/packages/vitnode/src/tanstack/admin/content/slots.ts index 07882ce49e..a100253e34 100644 --- a/packages/vitnode/src/tanstack/admin/content/slots.ts +++ b/packages/vitnode/src/tanstack/admin/content/slots.ts @@ -1,3 +1,4 @@ +import type { ContentId } from "@/content/ids"; import type { RegisteredFrontendContentType } from "@/content/index"; import type { ContentEditorialActionId } from "@/views/admin/views/content/actions/row-actions-model"; import type { ContentRowData } from "@/views/admin/views/content/table/cells"; @@ -33,7 +34,7 @@ export interface ContentRowPanelProps { currentVersion: number; entry: RegisteredFrontendContentType; finalFocus: React.RefObject; - itemId: number; + itemId: ContentId; /** The language the list is being read in, for a localized content type. */ locale?: string; onOpenChange: (open: boolean) => void; diff --git a/packages/vitnode/src/tests/memory-db.ts b/packages/vitnode/src/tests/memory-db.ts index 892c94a2b5..6e8ebd8d9f 100644 --- a/packages/vitnode/src/tests/memory-db.ts +++ b/packages/vitnode/src/tests/memory-db.ts @@ -9,6 +9,7 @@ import { StringChunk, Table, } from "drizzle-orm"; +import { randomUUID } from "node:crypto"; export type MemoryRow = Record; @@ -647,6 +648,21 @@ const isSerial = (column: Column) => column.columnType.includes("Serial") || column.generatedIdentity !== undefined; +/** A string-mode `bigint` identity hands its values out as decimal strings. */ +const isStringIdentity = (column: Column) => + column.generatedIdentity !== undefined && + column.columnType === "PgBigIntString"; + +/** `uuid().defaultRandom()` - the one SQL default the memory database fakes. */ +const isRandomUuid = (column: Column) => + column.columnType === "PgUUID" && + is(column.default, SQL) && + column.default.queryChunks.some( + chunk => + is(chunk, StringChunk) && + chunk.value.join("").includes("gen_random_uuid"), + ); + const uniqueViolation = (table: Table, key: string) => Object.assign( new Error( @@ -692,13 +708,21 @@ export const createMemoryDb = (seed: [Table, readonly object[]][] = []) => { type Undo = () => void; const nextSerial = (table: Table, key: string): number => - rowsOf(table).reduce( - (highest, row) => - typeof row[key] === "number" ? Math.max(highest, row[key]) : highest, - 0, - ) + 1; + rowsOf(table).reduce((highest, row) => { + const value = row[key]; + const numeric = + typeof value === "number" + ? value + : typeof value === "string" && /^[0-9]+$/.test(value) + ? Number(value) + : null; + + return numeric === null ? highest : Math.max(highest, numeric); + }, 0) + 1; const defaultOf = (table: Table, key: string, column: Column): unknown => { + if (isRandomUuid(column)) return randomUUID(); + if (isStringIdentity(column)) return String(nextSerial(table, key)); if (column.defaultFn) return column.defaultFn(); if (column.onUpdateFn) return column.onUpdateFn(); if (column.default !== undefined) { diff --git a/packages/vitnode/src/tests/revisions-table.ts b/packages/vitnode/src/tests/revisions-table.ts index 35469113ad..f9f17ae230 100644 --- a/packages/vitnode/src/tests/revisions-table.ts +++ b/packages/vitnode/src/tests/revisions-table.ts @@ -21,7 +21,8 @@ const boundValue = (condition: SQL | undefined, column: string): unknown => { export interface RevisionWrite { changedFields: string[]; - itemId: number; + /** `contentIdKey(id)`: the shared table stores every strategy's key as text. */ + itemId: string; languageId: null | number; operation: string; restoredFromRevisionId: null | number; @@ -32,7 +33,7 @@ export interface RevisionWrite { interface RevisionRow { [column: string]: unknown; id: number; - itemId: number; + itemId: number | string; languageId: null | number; version: number; } @@ -41,7 +42,9 @@ const inScope = (row: RevisionRow, condition: SQL | undefined): boolean => { const id = boundValue(condition, "id"); return ( - row.itemId === boundValue(condition, "itemId") && + // Compared as the stored key, so a seed written as `7` matches a scope + // bound as `"7"` exactly as the varchar column would. + String(row.itemId) === String(boundValue(condition, "itemId")) && row.languageId === boundValue(condition, "languageId") && (id === undefined || row.id === id) ); diff --git a/packages/vitnode/src/views/admin/views/content/actions/content-form.tsx b/packages/vitnode/src/views/admin/views/content/actions/content-form.tsx index 5f6b13a12f..381da79dfa 100644 --- a/packages/vitnode/src/views/admin/views/content/actions/content-form.tsx +++ b/packages/vitnode/src/views/admin/views/content/actions/content-form.tsx @@ -6,6 +6,7 @@ import type { ItemAutoFormComponentProps } from "@/components/form/auto-form"; import type { ContentFormSpec } from "@/content/admin/spec"; import type { ContentFileFieldValue } from "@/content/files"; import type { ContentVisibilityAction } from "@/content/visibility"; +import type { ContentId } from "@/content/ids"; import type { ContentFormLayout } from "@/lib/plugin"; import { AutoForm, type AutoFormOnSubmit } from "@/components/form/auto-form"; @@ -57,7 +58,7 @@ import { overlayContentDrafts } from "../live/values"; import { ConflictNotice } from "./conflict-notice"; export interface ContentFormProps { - data?: Record & { id: number }; + data?: Record & { id: ContentId }; fieldOverrides?: Record< string, (props: ItemAutoFormComponentProps) => React.ReactNode @@ -65,7 +66,7 @@ export interface ContentFormProps { fieldSkeletons?: ContentFormSkeletonOverrides; header?: ContentFormHeaderValue; layout?: ContentFormLayout; - onCreated?: (id: number) => void; + onCreated?: (id: ContentId) => void; presentation?: "dialog" | "page"; publication?: boolean; singular: string; diff --git a/packages/vitnode/src/views/admin/views/content/actions/delivery-action.tsx b/packages/vitnode/src/views/admin/views/content/actions/delivery-action.tsx index d1d26551e2..b96a0575c0 100644 --- a/packages/vitnode/src/views/admin/views/content/actions/delivery-action.tsx +++ b/packages/vitnode/src/views/admin/views/content/actions/delivery-action.tsx @@ -1,6 +1,8 @@ import React from "react"; import { useTranslations } from "use-intl"; +import type { ContentId } from "@/content/ids"; + import type { ContentPanelProps } from "./content-panel"; import { ContentPanel } from "./content-panel"; @@ -23,7 +25,7 @@ export const DeliveryContentPanel = ({ ...panel }: ContentPanelProps & { contentTypeId: string; - id: number; + id: ContentId; locale?: string; singular: string; }) => { diff --git a/packages/vitnode/src/views/admin/views/content/actions/delivery/delivery-panel.tsx b/packages/vitnode/src/views/admin/views/content/actions/delivery/delivery-panel.tsx index 8780c51183..9418d93eb3 100644 --- a/packages/vitnode/src/views/admin/views/content/actions/delivery/delivery-panel.tsx +++ b/packages/vitnode/src/views/admin/views/content/actions/delivery/delivery-panel.tsx @@ -2,6 +2,8 @@ import { useQuery } from "@tanstack/react-query"; import { CheckIcon, LinkIcon, XIcon } from "lucide-react"; import { useTranslations } from "use-intl"; +import type { ContentId } from "@/content/ids"; + import { DateFormat } from "@/components/date-format"; import { Badge } from "@/components/ui/badge"; import { Spinner } from "@/components/ui/spinner"; @@ -15,7 +17,7 @@ export const DeliveryPanel = ({ locale, }: { contentTypeId: string; - id: number; + id: ContentId; locale?: string; }) => { const t = useTranslations("core.content.delivery"); diff --git a/packages/vitnode/src/views/admin/views/content/actions/editorial-api.ts b/packages/vitnode/src/views/admin/views/content/actions/editorial-api.ts index 9ef32e000a..daf1d74a20 100644 --- a/packages/vitnode/src/views/admin/views/content/actions/editorial-api.ts +++ b/packages/vitnode/src/views/admin/views/content/actions/editorial-api.ts @@ -1,5 +1,6 @@ import { z } from "zod"; +import type { ContentId } from "@/content/ids"; import type { ContentRevisionDetail, ContentRevisionMeta, @@ -97,7 +98,7 @@ const EMPTY_PAGE_INFO = { endCursor: null, hasNextPage: false } as const; export const listContentRevisionsInBrowser = async ( target: ContentApiTarget, - id: number, + id: ContentId, cursor?: number, ): Promise => { const result = await sendContentApiRequest( @@ -122,7 +123,7 @@ export const listContentRevisionsInBrowser = async ( export const getContentRevisionInBrowser = async ( target: ContentApiTarget, - id: number, + id: ContentId, revisionId: number, ): Promise<{ error?: string; revision?: ContentRevisionDetail }> => { const result = await sendContentApiRequest( @@ -146,7 +147,7 @@ export const getContentRevisionInBrowser = async ( */ export const restoreContentRevisionInBrowser = async ( target: ContentApiTarget, - id: number, + id: ContentId, revisionId: number, expectedVersion: number, ): Promise => { @@ -180,7 +181,7 @@ export const restoreContentRevisionInBrowser = async ( */ export const createContentPreviewInBrowser = async ( target: ContentApiTarget, - id: number, + id: ContentId, ): Promise => { const result = await sendContentApiRequest( { method: "post", path: `/${id}/preview`, target }, @@ -200,7 +201,7 @@ export const createContentPreviewInBrowser = async ( export const listContentSchedulesInBrowser = async ( target: ContentApiTarget, - id: number, + id: ContentId, ): Promise => { const result = await sendContentApiRequest( { method: "get", path: `/${id}/schedules`, target }, @@ -230,7 +231,7 @@ export const listContentSchedulesInBrowser = async ( */ export const scheduleContentInBrowser = async ( target: ContentApiTarget, - id: number, + id: ContentId, action: ContentScheduleAction, scheduledFor: string, ): Promise => { @@ -251,7 +252,7 @@ export const scheduleContentInBrowser = async ( export const cancelContentScheduleInBrowser = async ( target: ContentApiTarget, - id: number, + id: ContentId, scheduleId: number, ): Promise => { const result = await sendContentApiRequest( @@ -281,7 +282,7 @@ export const cancelContentScheduleInBrowser = async ( */ export const readContentDeliveryInBrowser = async ( target: ContentApiTarget, - id: number, + id: ContentId, locale?: string, ): Promise => { const result = await sendContentApiRequest( diff --git a/packages/vitnode/src/views/admin/views/content/actions/editorial-query.ts b/packages/vitnode/src/views/admin/views/content/actions/editorial-query.ts index a3ff011676..38fed1d33b 100644 --- a/packages/vitnode/src/views/admin/views/content/actions/editorial-query.ts +++ b/packages/vitnode/src/views/admin/views/content/actions/editorial-query.ts @@ -1,5 +1,6 @@ import { infiniteQueryOptions, queryOptions } from "@tanstack/react-query"; +import type { ContentId } from "@/content/ids"; import type { ContentRevisionDetail, ContentRevisionMeta, @@ -21,18 +22,18 @@ import { /** The one entry the whole revision timeline pages into. */ export const contentHistoryListQueryKey = ( contentTypeId: string, - itemId: number, + itemId: ContentId, ) => contentHistoryQueryKey(contentTypeId, itemId, { kind: "list" }); export const contentRevisionQueryKey = ( contentTypeId: string, - itemId: number, + itemId: ContentId, revisionId: number, ) => contentHistoryQueryKey(contentTypeId, itemId, { revision: revisionId }); export const contentDeliveryLocaleQueryKey = ( contentTypeId: string, - itemId: number, + itemId: ContentId, locale: string | undefined, ) => [...contentDeliveryQueryKey(contentTypeId, itemId), locale ?? ""] as const; @@ -46,7 +47,7 @@ export const contentSchedulesQueryOptions = ({ listSchedules, }: { contentTypeId: string; - itemId: number; + itemId: ContentId; listSchedules: ContentEditorialTransport["listSchedules"]; }) => queryOptions({ @@ -61,7 +62,7 @@ export const contentRevisionHistoryQueryOptions = ({ listRevisions, }: { contentTypeId: string; - itemId: number; + itemId: ContentId; listRevisions: ContentEditorialTransport["listRevisions"]; }) => infiniteQueryOptions< @@ -87,7 +88,7 @@ export const contentRevisionQueryOptions = ({ }: { contentTypeId: string; getRevision: ContentEditorialTransport["getRevision"]; - itemId: number; + itemId: ContentId; revisionId: number; }) => queryOptions<{ error?: string; revision?: ContentRevisionDetail }>({ @@ -104,7 +105,7 @@ export const contentDeliveryQueryOptions = ({ readDelivery, }: { contentTypeId: string; - itemId: number; + itemId: ContentId; locale?: string; readDelivery: ContentEditorialTransport["readDelivery"]; }) => diff --git a/packages/vitnode/src/views/admin/views/content/actions/editorial-transport.tsx b/packages/vitnode/src/views/admin/views/content/actions/editorial-transport.tsx index fdc6591fff..fa8f0f63f2 100644 --- a/packages/vitnode/src/views/admin/views/content/actions/editorial-transport.tsx +++ b/packages/vitnode/src/views/admin/views/content/actions/editorial-transport.tsx @@ -1,5 +1,6 @@ import React from "react"; +import type { ContentId } from "@/content/ids"; import type { ContentRevisionDetail } from "@/content/revisions"; import type { ContentScheduleAction } from "@/content/schedules"; @@ -15,7 +16,7 @@ export type ContentEditorialWriteScope = "record" | "schedules"; export interface ContentEditorialSettled { contentTypeId: string; - itemId: number; + itemId: ContentId; scope: ContentEditorialWriteScope; } @@ -23,48 +24,48 @@ export interface ContentEditorialTransport { /** Cancels one pending schedule. Already-run schedules cannot be cancelled. */ cancelSchedule: ( contentTypeId: string, - itemId: number, + itemId: ContentId, scheduleId: number, ) => Promise; createPreview: ( contentTypeId: string, - itemId: number, + itemId: ContentId, ) => Promise; /** One revision's snapshot, read when a row is expanded and not before. */ getRevision: ( contentTypeId: string, - itemId: number, + itemId: ContentId, revisionId: number, ) => Promise<{ error?: string; revision?: ContentRevisionDetail }>; listRevisions: ( contentTypeId: string, - itemId: number, + itemId: ContentId, cursor?: number, ) => Promise; /** Every schedule on one record, and whether anything will run them. */ listSchedules: ( contentTypeId: string, - itemId: number, + itemId: ContentId, ) => Promise; readDelivery: ( contentTypeId: string, - itemId: number, + itemId: ContentId, locale?: string, ) => Promise; restoreRevision: ( contentTypeId: string, - itemId: number, + itemId: ContentId, revisionId: number, expectedVersion: number, ) => Promise; /** Books a publication or an unpublication for a moment in the future. */ schedule: ( contentTypeId: string, - itemId: number, + itemId: ContentId, action: ContentScheduleAction, scheduledFor: string, ) => Promise; diff --git a/packages/vitnode/src/views/admin/views/content/actions/history-action.tsx b/packages/vitnode/src/views/admin/views/content/actions/history-action.tsx index e8dc3780de..23f215599a 100644 --- a/packages/vitnode/src/views/admin/views/content/actions/history-action.tsx +++ b/packages/vitnode/src/views/admin/views/content/actions/history-action.tsx @@ -2,6 +2,7 @@ import React from "react"; import { useTranslations } from "use-intl"; import type { ContentFormSpec } from "@/content/admin/spec"; +import type { ContentId } from "@/content/ids"; import type { ContentPanelProps } from "./content-panel"; @@ -27,7 +28,7 @@ export const HistoryContentPanel = ({ }: ContentPanelProps & { contentTypeId: string; currentVersion: number; - id: number; + id: ContentId; permissionModule: string; pluginId: string; singular: string; diff --git a/packages/vitnode/src/views/admin/views/content/actions/history/revision-diff.tsx b/packages/vitnode/src/views/admin/views/content/actions/history/revision-diff.tsx index 84d8ea8854..f2f1da9d2a 100644 --- a/packages/vitnode/src/views/admin/views/content/actions/history/revision-diff.tsx +++ b/packages/vitnode/src/views/admin/views/content/actions/history/revision-diff.tsx @@ -83,10 +83,13 @@ const Value = ({ } if (Array.isArray(value)) { - if (typeof value[0] === "number") { + // A list of identifiers: numbers for users, files and serial targets, + // canonical strings for `uuid` and `bigint` targets. Anything else in an + // array is a repeatable's rows. + if (typeof value[0] === "number" || typeof value[0] === "string") { return ( - {(value as number[]) + {(value as (number | string)[]) .map(id => labels[String(id)] ?? `#${id}`) .join(", ")} diff --git a/packages/vitnode/src/views/admin/views/content/actions/history/revision-history.tsx b/packages/vitnode/src/views/admin/views/content/actions/history/revision-history.tsx index 141ef4e2df..8ae83a3e72 100644 --- a/packages/vitnode/src/views/admin/views/content/actions/history/revision-history.tsx +++ b/packages/vitnode/src/views/admin/views/content/actions/history/revision-history.tsx @@ -4,6 +4,7 @@ import React from "react"; import { useTranslations } from "use-intl"; import type { ContentFormSpec } from "@/content/admin/spec"; +import type { ContentId } from "@/content/ids"; import { useAdminStaffPermission } from "@/components/staff-permission/provider"; import { Button } from "@/components/ui/button"; @@ -21,7 +22,7 @@ import { RevisionRow } from "./revision-row"; interface RevisionHistoryProps { contentTypeId: string; currentVersion: number; - id: number; + id: ContentId; permissionModule: string; pluginId: string; singular: string; diff --git a/packages/vitnode/src/views/admin/views/content/actions/history/revision-row.tsx b/packages/vitnode/src/views/admin/views/content/actions/history/revision-row.tsx index 1108bb2f1e..1f4e993e74 100644 --- a/packages/vitnode/src/views/admin/views/content/actions/history/revision-row.tsx +++ b/packages/vitnode/src/views/admin/views/content/actions/history/revision-row.tsx @@ -6,6 +6,7 @@ import { toast } from "sonner"; import { useTranslations } from "use-intl"; import type { ContentFormSpec } from "@/content/admin/spec"; +import type { ContentId } from "@/content/ids"; import type { ContentRevisionMeta, ContentRevisionOperation, @@ -87,7 +88,7 @@ const useRevisionSnapshot = ({ }: { contentTypeId: string; enabled: boolean; - itemId: number; + itemId: ContentId; revisionId: null | number; }) => { const transport = useContentEditorialTransport(); @@ -116,7 +117,7 @@ const RevisionRestoreAction = ({ }: { contentTypeId: string; currentVersion: number; - id: number; + id: ContentId; onRestored: () => void; revision: ContentRevisionMeta; singular: string; @@ -255,7 +256,7 @@ export const RevisionRow = ({ canRestore: boolean; contentTypeId: string; currentVersion: number; - id: number; + id: ContentId; isCurrent: boolean; onRestored: () => void; previousId: null | number; diff --git a/packages/vitnode/src/views/admin/views/content/actions/preview-action.tsx b/packages/vitnode/src/views/admin/views/content/actions/preview-action.tsx index 85acc0c6a9..f13c8e0c5d 100644 --- a/packages/vitnode/src/views/admin/views/content/actions/preview-action.tsx +++ b/packages/vitnode/src/views/admin/views/content/actions/preview-action.tsx @@ -3,6 +3,8 @@ import React from "react"; import { toast } from "sonner"; import { useTranslations } from "use-intl"; +import type { ContentId } from "@/content/ids"; + import { DateFormat } from "@/components/date-format"; import { Button } from "@/components/ui/button"; import { CopyButton } from "@/components/ui/copy-button"; @@ -19,7 +21,7 @@ const PreviewLink = ({ id, }: { contentTypeId: string; - id: number; + id: ContentId; }) => { const t = useTranslations("core.content.preview"); const tErrors = useTranslations("core.global.errors"); @@ -114,7 +116,7 @@ export const PreviewContentPanel = ({ ...panel }: ContentPanelProps & { contentTypeId: string; - id: number; + id: ContentId; title: string; }) => { const t = useTranslations("core.content.preview"); diff --git a/packages/vitnode/src/views/admin/views/content/actions/schedule-action.tsx b/packages/vitnode/src/views/admin/views/content/actions/schedule-action.tsx index ddffe07538..87c6dfdf7d 100644 --- a/packages/vitnode/src/views/admin/views/content/actions/schedule-action.tsx +++ b/packages/vitnode/src/views/admin/views/content/actions/schedule-action.tsx @@ -1,6 +1,8 @@ import React from "react"; import { useTranslations } from "use-intl"; +import type { ContentId } from "@/content/ids"; + import type { ContentPanelProps } from "./content-panel"; import { ContentPanel } from "./content-panel"; @@ -22,7 +24,7 @@ export const ScheduleContentPanel = ({ ...panel }: ContentPanelProps & { contentTypeId: string; - id: number; + id: ContentId; singular: string; title: string; }) => { diff --git a/packages/vitnode/src/views/admin/views/content/actions/schedule/schedule-panel.tsx b/packages/vitnode/src/views/admin/views/content/actions/schedule/schedule-panel.tsx index c872677a51..d0130d7d50 100644 --- a/packages/vitnode/src/views/admin/views/content/actions/schedule/schedule-panel.tsx +++ b/packages/vitnode/src/views/admin/views/content/actions/schedule/schedule-panel.tsx @@ -6,6 +6,7 @@ import { useTranslations } from "use-intl"; import { z } from "zod"; import type { AutoFormOnSubmit } from "@/components/form/auto-form"; +import type { ContentId } from "@/content/ids"; import type { ContentSchedule } from "@/content/schedules"; import { DateFormat } from "@/components/date-format"; @@ -111,7 +112,7 @@ export const SchedulePanel = ({ title, }: { contentTypeId: string; - id: number; + id: ContentId; singular: string; title: string; }) => { diff --git a/packages/vitnode/src/views/admin/views/content/content-mutation.ts b/packages/vitnode/src/views/admin/views/content/content-mutation.ts index 8a01677cd5..22728c9607 100644 --- a/packages/vitnode/src/views/admin/views/content/content-mutation.ts +++ b/packages/vitnode/src/views/admin/views/content/content-mutation.ts @@ -6,10 +6,11 @@ import type { ContentTranslationConflict, ContentUnprocessable, } from "@/content/conflicts"; +import type { ContentId } from "@/content/ids"; /** One language's row, as the tab strip, the panel and the form read it. */ export interface TranslationRow { - itemId: number; + itemId: ContentId; languageId: number; locale: string; publishedAt?: null | string; @@ -26,7 +27,7 @@ export interface ContentTranslationInput { } /** Anything the generated routes return: an identifier plus the row's fields. */ -export type ContentRow = Record & { id: number }; +export type ContentRow = Record & { id: ContentId }; /** A re-read of one record, for the conflict banner. */ export interface ContentRowResult { @@ -42,7 +43,7 @@ export interface ContentMutationResult { duplicate?: ContentDuplicateRejection; error?: string; - id?: number; + id?: ContentId; /** Why a schedule was refused, when the API said. */ rejection?: ContentScheduleRejection; /** Lets the UI tell a restricted delete (409) from a generic failure. */ diff --git a/packages/vitnode/src/views/admin/views/content/content-query.ts b/packages/vitnode/src/views/admin/views/content/content-query.ts index d1ea004a8b..0f02245b9b 100644 --- a/packages/vitnode/src/views/admin/views/content/content-query.ts +++ b/packages/vitnode/src/views/admin/views/content/content-query.ts @@ -1,3 +1,5 @@ +import type { ContentId } from "@/content/ids"; + import { adminQueryRoot } from "@/views/admin/table/query"; /** The screen name this family lives under - `["vitnode","admin","content"]`. */ @@ -18,8 +20,10 @@ export const contentListQueryRoot = (contentTypeId: string) => export const contentListQueryKey = (contentTypeId: string, params: object) => [...contentListQueryRoot(contentTypeId), params] as const; -export const contentItemQueryRoot = (contentTypeId: string, itemId: number) => - [...contentTypeQueryRoot(contentTypeId), "item", itemId] as const; +export const contentItemQueryRoot = ( + contentTypeId: string, + itemId: ContentId, +) => [...contentTypeQueryRoot(contentTypeId), "item", itemId] as const; /** The record itself. */ export const contentItemQueryKey = contentItemQueryRoot; @@ -27,31 +31,31 @@ export const contentItemQueryKey = contentItemQueryRoot; /** Every translation of one record, values included. */ export const contentTranslationsQueryKey = ( contentTypeId: string, - itemId: number, + itemId: ContentId, ) => [...contentItemQueryRoot(contentTypeId, itemId), "translations"] as const; export const contentHistoryQueryRoot = ( contentTypeId: string, - itemId: number, + itemId: ContentId, ) => [...contentItemQueryRoot(contentTypeId, itemId), "history"] as const; /** One page of that history, or one revision, named by the request. */ export const contentHistoryQueryKey = ( contentTypeId: string, - itemId: number, + itemId: ContentId, request: object, ) => [...contentHistoryQueryRoot(contentTypeId, itemId), request] as const; /** One record's publication schedules. */ export const contentSchedulesQueryKey = ( contentTypeId: string, - itemId: number, + itemId: ContentId, ) => [...contentItemQueryRoot(contentTypeId, itemId), "schedules"] as const; /** One record's delivery state - its canonical path and URL history. */ export const contentDeliveryQueryKey = ( contentTypeId: string, - itemId: number, + itemId: ContentId, ) => [...contentItemQueryRoot(contentTypeId, itemId), "delivery"] as const; export const contentOptionsQueryRoot = (target: string) => diff --git a/packages/vitnode/src/views/admin/views/content/form/contract.test-d.ts b/packages/vitnode/src/views/admin/views/content/form/contract.test-d.ts index f3b0ca74ac..c92574e023 100644 --- a/packages/vitnode/src/views/admin/views/content/form/contract.test-d.ts +++ b/packages/vitnode/src/views/admin/views/content/form/contract.test-d.ts @@ -35,7 +35,7 @@ describe("a plugin's form layout", () => { // engine decides what happens when the form is submitted. expectTypeOf[0]>().toEqualTypeOf<{ contentTypeId: string; - itemId?: number; + itemId?: number | string; mode: "create" | "edit"; pluginId: string; publication: boolean; diff --git a/packages/vitnode/src/views/admin/views/content/form/item-query.ts b/packages/vitnode/src/views/admin/views/content/form/item-query.ts index 0d633b3702..83e06da926 100644 --- a/packages/vitnode/src/views/admin/views/content/form/item-query.ts +++ b/packages/vitnode/src/views/admin/views/content/form/item-query.ts @@ -1,6 +1,7 @@ import { queryOptions } from "@tanstack/react-query"; import { z } from "zod"; +import type { ContentId } from "@/content/ids"; import type { AnyContentTypeDefinition } from "@/content/types"; import { resolveLangValue } from "@/lib/helpers/multi-lang"; @@ -22,7 +23,7 @@ export const zodContentItem = z }) .loose(); -export type ContentItem = Record & { id: number }; +export type ContentItem = Record & { id: ContentId }; export const zodContentTranslationList = z.object({ edges: z.array(z.object({ locale: z.string() }).loose()), @@ -31,7 +32,7 @@ export const zodContentTranslationList = z.object({ /** Which record is being read, and from which generated module. */ export interface ContentItemRequest { contentTypeId: string; - itemId: number; + itemId: ContentId; target: ContentApiTarget; } diff --git a/packages/vitnode/src/views/admin/views/content/form/mutations-api.ts b/packages/vitnode/src/views/admin/views/content/form/mutations-api.ts index 3f58e692a6..ecaea813fa 100644 --- a/packages/vitnode/src/views/admin/views/content/form/mutations-api.ts +++ b/packages/vitnode/src/views/admin/views/content/form/mutations-api.ts @@ -1,5 +1,6 @@ import { z } from "zod"; +import type { ContentId } from "@/content/ids"; import type { ContentPublicationAction } from "@/content/publication"; import type { ContentVisibilityAction } from "@/content/visibility"; @@ -44,7 +45,7 @@ const zodOptions = z.object({ const readTranslations = async ( target: ContentApiTarget, - id: number, + id: ContentId, ): Promise => { const result = await send( { method: "get", path: `/${id}/translations`, target }, @@ -76,7 +77,7 @@ export const editContentInBrowser = async ( /** Whether this content type's `PUT` takes a precondition at all. */ editorial: boolean; expectedVersion?: number; - id: number; + id: ContentId; values: Record; }, ): Promise => { @@ -146,7 +147,7 @@ export const editLocalizedContentInBrowser = async ( values, }: { expectedVersion?: number; - id: number; + id: ContentId; translations: ContentTranslationInput[]; values: Record | undefined; }, @@ -189,7 +190,7 @@ export const editLocalizedContentInBrowser = async ( */ export const setContentPublishedInBrowser = async ( target: ContentApiTarget, - id: number, + id: ContentId, action: ContentPublicationAction, ): Promise => { const result = await send( @@ -214,7 +215,7 @@ export const setContentPublishedInBrowser = async ( */ export const setContentHiddenInBrowser = async ( target: ContentApiTarget, - id: number, + id: ContentId, action: ContentVisibilityAction, expectedVersion?: number, ): Promise => { @@ -236,7 +237,7 @@ export const setContentHiddenInBrowser = async ( /** Re-reads one record - for the conflict banner and for missing collections. */ export const readContentRowInBrowser = async ( target: ContentApiTarget, - id: number, + id: ContentId, ): Promise => { const result = await send({ method: "get", path: `/${id}`, target }, zodRow); @@ -248,7 +249,7 @@ export const readContentRowInBrowser = async ( /** Every language one record exists in, values included, in one request. */ export const listContentTranslationsInBrowser = async ( target: ContentApiTarget, - id: number, + id: ContentId, ): Promise<{ edges: TranslationRow[]; error?: string }> => { const result = await send( { method: "get", path: `/${id}/translations`, target }, @@ -274,7 +275,7 @@ export const loadContentOptionsInBrowser = async ( target: ContentApiTarget, field: string, search: string, - ids?: number[], + ids?: ContentId[], ): Promise => { const result = await send( { diff --git a/packages/vitnode/src/views/admin/views/content/form/transport.tsx b/packages/vitnode/src/views/admin/views/content/form/transport.tsx index 6f143f7831..e3902316ed 100644 --- a/packages/vitnode/src/views/admin/views/content/form/transport.tsx +++ b/packages/vitnode/src/views/admin/views/content/form/transport.tsx @@ -3,6 +3,7 @@ import React from "react"; import type { ContentDrafts } from "@/content/live/http"; import type { ContentFieldLock } from "@/content/live/protocol"; import type { ContentVisibilityAction } from "@/content/visibility"; +import type { ContentId } from "@/content/ids"; import type { ContentMutationResult, @@ -37,14 +38,14 @@ export interface ContentFormTransport { edit: ( contentTypeId: string, - itemId: number, + itemId: ContentId, values: Record, expectedVersion?: number, ) => Promise; editLocalized: ( contentTypeId: string, - itemId: number, + itemId: ContentId, values: Record | undefined, translations: ContentTranslationInput[], expectedVersion?: number, @@ -57,14 +58,14 @@ export interface ContentFormTransport { listTranslations: ( contentTypeId: string, - itemId: number, + itemId: ContentId, ) => Promise<{ edges: TranslationRow[]; error?: string }>; loadOptions: ( contentTypeId: string, field: string, search: string, - ids?: number[], + ids?: ContentId[], ) => Promise; lock: ( contentTypeId: string, @@ -79,7 +80,7 @@ export interface ContentFormTransport { /** Moves a record to `published`. Idempotent: a no-op is a success. */ publish: ( contentTypeId: string, - itemId: number, + itemId: ContentId, ) => Promise; readDraft: ( @@ -89,7 +90,7 @@ export interface ContentFormTransport { reloadRow: ( contentTypeId: string, - itemId: number, + itemId: ContentId, ) => Promise; saveDraft: ( @@ -112,7 +113,7 @@ export interface ContentFormTransport { /** Moves a record back to `draft`. Idempotent, like {@link publish}. */ unpublish: ( contentTypeId: string, - itemId: number, + itemId: ContentId, ) => Promise; } diff --git a/packages/vitnode/src/views/admin/views/content/lib/field-component.tsx b/packages/vitnode/src/views/admin/views/content/lib/field-component.tsx index b9b933c1c3..8dca5d66b9 100644 --- a/packages/vitnode/src/views/admin/views/content/lib/field-component.tsx +++ b/packages/vitnode/src/views/admin/views/content/lib/field-component.tsx @@ -7,6 +7,7 @@ import type { ContentFileDescriptor, ContentFileFieldValue, } from "@/content/files"; +import type { ContentId } from "@/content/ids"; import { AiFieldAssist } from "@/components/ai/ai-field-assist"; import { AutoFormCombobox } from "@/components/form/fields/combobox"; @@ -40,7 +41,8 @@ export interface ContentOption { export type ContentOptionsLoader = (args: { field: string; - ids?: number[]; + /** The target's ids: numbers for users and serial targets, strings otherwise. */ + ids?: ContentId[]; search: string; }) => Promise; diff --git a/packages/vitnode/src/views/admin/views/content/lib/invalidate.ts b/packages/vitnode/src/views/admin/views/content/lib/invalidate.ts index 9b90d5b427..bafb968c86 100644 --- a/packages/vitnode/src/views/admin/views/content/lib/invalidate.ts +++ b/packages/vitnode/src/views/admin/views/content/lib/invalidate.ts @@ -1,5 +1,7 @@ import type { QueryClient } from "@tanstack/react-query"; +import type { ContentId } from "@/content/ids"; + import { contentDeliveryQueryKey, contentHistoryQueryRoot, @@ -24,7 +26,7 @@ export const invalidateContentList = async ( export const invalidateContentItem = async ( queryClient: QueryClient, contentTypeId: string, - itemId: number, + itemId: ContentId, ): Promise => { await queryClient.invalidateQueries({ queryKey: contentItemQueryRoot(contentTypeId, itemId), @@ -35,7 +37,7 @@ export const invalidateContentItem = async ( export const removeContentItem = ( queryClient: QueryClient, contentTypeId: string, - itemId: number, + itemId: ContentId, ): void => { queryClient.removeQueries({ queryKey: contentItemQueryRoot(contentTypeId, itemId), @@ -45,7 +47,7 @@ export const removeContentItem = ( export const invalidateContentHistory = async ( queryClient: QueryClient, contentTypeId: string, - itemId: number, + itemId: ContentId, ): Promise => { await queryClient.invalidateQueries({ queryKey: contentHistoryQueryRoot(contentTypeId, itemId), @@ -56,7 +58,7 @@ export const invalidateContentHistory = async ( export const invalidateContentTranslations = async ( queryClient: QueryClient, contentTypeId: string, - itemId: number, + itemId: ContentId, ): Promise => { await queryClient.invalidateQueries({ queryKey: contentTranslationsQueryKey(contentTypeId, itemId), @@ -67,7 +69,7 @@ export const invalidateContentTranslations = async ( export const invalidateContentSchedules = async ( queryClient: QueryClient, contentTypeId: string, - itemId: number, + itemId: ContentId, ): Promise => { await queryClient.invalidateQueries({ queryKey: contentSchedulesQueryKey(contentTypeId, itemId), @@ -78,7 +80,7 @@ export const invalidateContentSchedules = async ( export const invalidateContentDelivery = async ( queryClient: QueryClient, contentTypeId: string, - itemId: number, + itemId: ContentId, ): Promise => { await queryClient.invalidateQueries({ queryKey: contentDeliveryQueryKey(contentTypeId, itemId), diff --git a/packages/vitnode/src/views/admin/views/content/lib/reference-options.ts b/packages/vitnode/src/views/admin/views/content/lib/reference-options.ts index b2acb4e844..b2d25ae165 100644 --- a/packages/vitnode/src/views/admin/views/content/lib/reference-options.ts +++ b/packages/vitnode/src/views/admin/views/content/lib/reference-options.ts @@ -1,34 +1,49 @@ import React from "react"; +import type { ContentId } from "@/content/ids"; + +import { contentIdKey } from "@/content/ids"; + import type { ContentOption, ContentOptionsLoader } from "./field-component"; +/** + * Labels for the identifiers a to-many picker holds, fetched once per id. + * + * Keyed by `contentIdKey` - the option's own string `value` - so a `serial` + * target's `7` and a `uuid` or `bigint` target's string ids are looked up the + * same way, and a bigint is never rounded through `Number`. + */ export const useReferenceOptions = ({ field, ids, load, }: { field: string; - ids: number[]; + ids: readonly ContentId[]; load: ContentOptionsLoader; }) => { - const [known, setKnown] = React.useState>({}); + const [known, setKnown] = React.useState>({}); - const [asked, setAsked] = React.useState>( + const [asked, setAsked] = React.useState>( () => new Set(), ); // The ids are a fresh array on every render; the *set* of them is what a - // lookup depends on, so the effect keys off the sorted key rather than the - // identity of the array. - const missing = ids.filter(id => known[id] === undefined && !asked.has(id)); + // lookup depends on, so the effect keys off the joined key rather than the + // identity of the array. Commas cannot occur in any strategy's id. + const missing = ids + .map(contentIdKey) + .filter(key => known[key] === undefined && !asked.has(key)); const missingKey = missing.join(","); React.useEffect(() => { if (missingKey === "") return; let active = true; - // oxlint-disable-next-line react-you-might-not-need-an-effect/no-pass-live-state-to-parent -- `load` is a fetch, not a parent callback: it takes the ids nobody has looked up yet and answers with their labels. The state it reads is the record of what has already been asked for, which is exactly what keeps the fetch from repeating. - const requested = missingKey.split(",").map(Number); + const requested = missingKey.split(","); + + // Sent as their keys: the options route reads each one under the target's + // own strategy, so `"7"` and `7` name the same serial record. void load({ field, ids: requested, @@ -39,9 +54,7 @@ export const useReferenceOptions = ({ setKnown(current => ({ ...current, - ...Object.fromEntries( - options.map(option => [Number(option.value), option]), - ), + ...Object.fromEntries(options.map(option => [option.value, option])), })); }) // Marked asked either way, and in a `finally` rather than beside the @@ -64,14 +77,16 @@ export const useReferenceOptions = ({ * Whether this id is still being looked up - so a chip can render a * skeleton rather than the identifier it is about to stop being. */ - pending: (id: number): boolean => known[id] === undefined && !asked.has(id), + pending: (id: ContentId): boolean => { + const key = contentIdKey(id); + + return known[key] === undefined && !asked.has(key); + }, /** Remembers what a picker just resolved, so a fresh choice reads in full. */ remember: (options: readonly ContentOption[]) => { setKnown(current => ({ ...current, - ...Object.fromEntries( - options.map(option => [Number(option.value), option]), - ), + ...Object.fromEntries(options.map(option => [option.value, option])), })); }, }; diff --git a/packages/vitnode/src/views/admin/views/content/lib/relation-set-field.tsx b/packages/vitnode/src/views/admin/views/content/lib/relation-set-field.tsx index 5f5a7c31eb..a338db0b4a 100644 --- a/packages/vitnode/src/views/admin/views/content/lib/relation-set-field.tsx +++ b/packages/vitnode/src/views/admin/views/content/lib/relation-set-field.tsx @@ -2,8 +2,11 @@ import { useLocale, useTranslations } from "use-intl"; import type { ItemAutoFormComponentProps } from "@/components/form/auto-form"; import type { ContentFormFieldSpec } from "@/content/admin/spec"; +import type { ContentId } from "@/content/ids"; import { AutoFormCombobox } from "@/components/form/fields/combobox"; +import { contentOptionValueToId } from "@/content/admin/spec"; +import { contentIdKey } from "@/content/ids"; import type { ContentOption, ContentOptionsLoader } from "./field-component"; @@ -13,7 +16,8 @@ import { ContentReferenceChipSkeleton } from "./reference-chip-skeleton"; import { useReferenceOptions } from "./reference-options"; export interface ContentRelationSetFieldProps extends ItemAutoFormComponentProps { - labels?: Record; + /** Labels the row already resolved, keyed by `contentIdKey(id)`. */ + labels?: Record; loadOptions: ContentOptionsLoader; spec: ContentFormFieldSpec; } @@ -27,17 +31,26 @@ export const ContentRelationSetField = ({ }: ContentRelationSetFieldProps) => { const t = useTranslations("core.content.form"); const locale = useLocale(); - const selected = Array.isArray(field.value) ? (field.value as number[]) : []; + // A number per serial target, a canonical string per `uuid` or `bigint` one. + const selected = Array.isArray(field.value) + ? (field.value as ContentId[]) + : []; const { known, pending, remember } = useReferenceOptions({ field: spec.name, ids: selected, load: loadOptions, }); - const optionFor = (id: number): ContentOption => - known[id] ?? { label: labels[id] ?? String(id), value: String(id) }; - const isPending = (id: number): boolean => - labels[id] === undefined && pending(id); + const optionFor = (id: ContentId): ContentOption => { + const key = contentIdKey(id); + + return known[key] ?? { label: labels[key] ?? key, value: key }; + }; + const isPending = (value: string): boolean => { + const id = contentOptionValueToId(spec, value); + + return id !== null && labels[value] === undefined && pending(id); + }; return ( Number(item.value))); + field.onChange( + items + .map(item => contentOptionValueToId(spec, item.value)) + .filter((id): id is ContentId => id !== null), + ); }, value: selected.map(optionFor), }} @@ -61,7 +78,7 @@ export const ContentRelationSetField = ({ placeholder={t("relation.placeholder")} queryKey={contentOptionsQueryKey(spec, locale)} renderChip={item => - isPending(Number(item.value)) ? ( + isPending(item.value) ? ( ) : ( diff --git a/packages/vitnode/src/views/admin/views/content/page/content-form-page.tsx b/packages/vitnode/src/views/admin/views/content/page/content-form-page.tsx index a4d55d1439..2718557208 100644 --- a/packages/vitnode/src/views/admin/views/content/page/content-form-page.tsx +++ b/packages/vitnode/src/views/admin/views/content/page/content-form-page.tsx @@ -2,8 +2,11 @@ import React from "react"; import type { ItemAutoFormComponentProps } from "@/components/form/auto-form"; import type { ContentFormSpec } from "@/content/admin/spec"; +import type { ContentId } from "@/content/ids"; import type { ContentFormLayout } from "@/lib/plugin"; +import { contentIdKey } from "@/content/ids"; + import type { TranslationRow } from "../content-mutation"; import type { ContentFormHeaderValue } from "../form/context"; import type { ContentFormSkeletonOverrides } from "../form/skeleton"; @@ -14,7 +17,7 @@ import { useContentFormNavigation } from "../form/navigation"; export interface ContentFormPageProps { backHref: string; createdHrefTemplate?: string; - data?: Record & { id: number }; + data?: Record & { id: ContentId }; fieldOverrides?: Record< string, (props: ItemAutoFormComponentProps) => React.ReactNode @@ -38,10 +41,10 @@ export const ContentFormPage = ({ }: ContentFormPageProps) => { const { navigate } = useContentFormNavigation(); - const onCreated = (id: number) => { + const onCreated = (id: ContentId) => { navigate( createdHrefTemplate - ? createdHrefTemplate.replace("{id}", String(id)) + ? createdHrefTemplate.replace("{id}", contentIdKey(id)) : backHref, ); }; diff --git a/packages/vitnode/src/views/admin/views/content/table/bulk-actions-model.ts b/packages/vitnode/src/views/admin/views/content/table/bulk-actions-model.ts index 7f5f87fe64..3e385c97a7 100644 --- a/packages/vitnode/src/views/admin/views/content/table/bulk-actions-model.ts +++ b/packages/vitnode/src/views/admin/views/content/table/bulk-actions-model.ts @@ -1,3 +1,5 @@ +import type { ContentId } from "@/content/ids"; + import type { ContentRowMutationResult } from "./list-mutations"; export const CONTENT_BULK_CONCURRENCY = 4; @@ -6,10 +8,10 @@ export const CONTENT_BULK_ACTIONS = ["publish", "unpublish", "delete"] as const; export type ContentBulkAction = (typeof CONTENT_BULK_ACTIONS)[number]; -export interface ContentBulkResult { +export interface ContentBulkResult { conflicted: number; failed: number; - succeeded: number[]; + succeeded: TId[]; } export const contentBulkActions = ({ @@ -30,11 +32,11 @@ const SERVER_ERROR: ContentRowMutationResult = { status: 500, }; -export const runContentBulkAction = async ( - ids: readonly number[], - runOne: (id: number) => Promise, -): Promise => { - const outcomes = new Map(); +export const runContentBulkAction = async ( + ids: readonly TId[], + runOne: (id: TId) => Promise, +): Promise> => { + const outcomes = new Map(); const queue = [...ids]; const worker = async () => { @@ -50,7 +52,11 @@ export const runContentBulkAction = async ( ), ); - const result: ContentBulkResult = { conflicted: 0, failed: 0, succeeded: [] }; + const result: ContentBulkResult = { + conflicted: 0, + failed: 0, + succeeded: [], + }; for (const id of ids) { const outcome = outcomes.get(id) ?? SERVER_ERROR; diff --git a/packages/vitnode/src/views/admin/views/content/table/cells.tsx b/packages/vitnode/src/views/admin/views/content/table/cells.tsx index 2b83a94872..b0cbcbbc8a 100644 --- a/packages/vitnode/src/views/admin/views/content/table/cells.tsx +++ b/packages/vitnode/src/views/admin/views/content/table/cells.tsx @@ -12,6 +12,7 @@ import type { ContentFileDescriptor, ContentFileFieldValue, } from "@/content/files"; +import type { ContentId } from "@/content/ids"; import type { ContentReferenceListItem } from "@/content/server/list-references"; import type { ContentLabels } from "@/content/server/service"; @@ -26,7 +27,7 @@ import { ContentHiddenBadge } from "../lib/hidden-badge"; export interface ContentRowData extends Record { files?: Record; - id: number; + id: ContentId; labels: ContentLabels; localizedValues?: Record; references?: Record; diff --git a/packages/vitnode/src/views/admin/views/content/table/list-mutations.ts b/packages/vitnode/src/views/admin/views/content/table/list-mutations.ts index aacd64125b..de729f080a 100644 --- a/packages/vitnode/src/views/admin/views/content/table/list-mutations.ts +++ b/packages/vitnode/src/views/admin/views/content/table/list-mutations.ts @@ -1,3 +1,4 @@ +import type { ContentId } from "@/content/ids"; import type { ContentPublicationAction } from "@/content/publication"; import type { ContentVisibilityAction } from "@/content/visibility"; @@ -40,7 +41,7 @@ const readResult = async ( }; export interface ContentRowMutationArgs { - id: number; + id: ContentId; target: ContentApiTarget; } @@ -70,13 +71,14 @@ export interface ContentDuplicateInput { /** What `POST /{id}/duplicate` answers with on a `201`. */ export interface ContentDuplicatePayload { - id: number; + /** A number for a `serial` content type, the canonical string otherwise. */ + id: ContentId; /** The copied translations' locales, default locale first. */ locales: string[]; row: Record; /** Source locales switched off in this install, which the copy does not carry. */ skippedLocales: string[]; - sourceId: number; + sourceId: ContentId; } export type ContentDuplicateMutationResult = ContentRowMutationResult & { diff --git a/packages/vitnode/src/views/admin/views/core/system/files/actions/files-bulk-actions.tsx b/packages/vitnode/src/views/admin/views/core/system/files/actions/files-bulk-actions.tsx index 9b3d67a6ac..caa1240125 100644 --- a/packages/vitnode/src/views/admin/views/core/system/files/actions/files-bulk-actions.tsx +++ b/packages/vitnode/src/views/admin/views/core/system/files/actions/files-bulk-actions.tsx @@ -55,7 +55,11 @@ export const FilesBulkActions = ({ if (!open) setHeldByRevisions([]); }} onSubmit={async ({ onClose }) => { - const result = await onDeleteFiles({ force: isForcing, ids }); + const result = await onDeleteFiles({ + force: isForcing, + // File rows are numbered; the table's selection is typed for any id. + ids: ids.filter((id): id is number => typeof id === "number"), + }); report(result); if (!isForcing && result.heldByRevisions.length > 0) { diff --git a/packages/vitnode/src/views/files/actions/files-bulk-actions.tsx b/packages/vitnode/src/views/files/actions/files-bulk-actions.tsx index 4ccce5c541..67697fbe29 100644 --- a/packages/vitnode/src/views/files/actions/files-bulk-actions.tsx +++ b/packages/vitnode/src/views/files/actions/files-bulk-actions.tsx @@ -55,7 +55,11 @@ export const MyFilesBulkActions = ({ if (!open) setHeldByRevisions([]); }} onSubmit={async ({ onClose }) => { - const result = await onDeleteFiles({ force: isForcing, ids }); + const result = await onDeleteFiles({ + force: isForcing, + // File rows are numbered; the table's selection is typed for any id. + ids: ids.filter((id): id is number => typeof id === "number"), + }); report(result); if (!isForcing && result.heldByRevisions.length > 0) { diff --git a/packages/vitnode/src/views/search/types.ts b/packages/vitnode/src/views/search/types.ts index 2dbec91a71..341467f167 100644 --- a/packages/vitnode/src/views/search/types.ts +++ b/packages/vitnode/src/views/search/types.ts @@ -13,7 +13,7 @@ export interface SearchResultItem { createdAt: Date | string; id: number; isPublic: boolean; - itemId: number; + itemId: number | string; itemType: string; languageCode: string; metadata: Record; From 0fdca17eff337cc1901f0e4935a17eea313a2b29 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 09:35:20 +0000 Subject: [PATCH 06/17] feat(content): uuid and bigint demo types, id strategy tests, Elasticsearch item keys Adds example.tag (uuid) and example.event (bigint) to the example plugin with a uuid tags relation on advanced-article, full admin wiring and locales. Covers id strategies with Postgres integration tests, the shared itemId varchar migration, type tests and parsing tests. Elasticsearch documents gain an itemKey keyword so non-numeric ids round-trip. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VpG3D33ZqXEYjN2N6eNcMK --- packages/elasticsearch/src/index.ts | 55 ++++- .../src/api/lib/pagination-cursor.test.ts | 51 +++++ .../vitnode/src/api/models/search.test.ts | 60 +++++ .../vitnode/src/content/admin/route.test.ts | 50 +++++ .../vitnode/src/content/id-strategy.test-d.ts | 204 +++++++++++++++++ packages/vitnode/src/content/ids.test.ts | 209 ++++++++++++++++++ .../src/content/server/preview-token.test.ts | 45 ++++ .../content/server/search-document.test.ts | 32 +++ .../vitnode/src/content/server/table.test.ts | 65 ++++++ packages/vitnode/src/content/server/types.ts | 5 +- .../vitnode/src/tests/content-fixtures.ts | 138 ++++++++++++ plugins/example/src/admin/content.tsx | 16 +- plugins/example/src/admin/nav.tsx | 24 +- .../src/api/modules/admin/admin.module.ts | 5 + plugins/example/src/config.api.ts | 4 + plugins/example/src/content.ts | 4 + .../example/src/content/advanced-article.ts | 11 +- plugins/example/src/content/event.ts | 131 +++++++++++ plugins/example/src/content/tag.ts | 66 ++++++ .../example/src/database/advanced-articles.ts | 6 +- plugins/example/src/database/events.ts | 27 +++ plugins/example/src/database/tags.ts | 18 ++ plugins/example/src/locales/en.json | 45 ++++ 23 files changed, 1256 insertions(+), 15 deletions(-) create mode 100644 packages/vitnode/src/content/id-strategy.test-d.ts create mode 100644 packages/vitnode/src/content/ids.test.ts create mode 100644 plugins/example/src/content/event.ts create mode 100644 plugins/example/src/content/tag.ts create mode 100644 plugins/example/src/database/events.ts create mode 100644 plugins/example/src/database/tags.ts diff --git a/packages/elasticsearch/src/index.ts b/packages/elasticsearch/src/index.ts index b5344e280b..8f620ccbc9 100644 --- a/packages/elasticsearch/src/index.ts +++ b/packages/elasticsearch/src/index.ts @@ -6,6 +6,7 @@ import type { SearchQueryParams, SearchResult, } from "@vitnode/core/api/models/search"; +import type { ContentId } from "@vitnode/core/content"; import { Client, errors } from "@elastic/elasticsearch"; @@ -48,7 +49,13 @@ interface EsSource { content: string; createdAt: string; isPublic: boolean; - itemId: number; + /** + * The numeric id, only for a numeric item - the original `integer` mapping + * cannot hold a `uuid` or a `bigint` key, so those carry `itemKey` alone. + */ + itemId?: number; + /** `contentIdKey(itemId)` - every item has one, whatever its id strategy. */ + itemKey?: string; itemType: string; languageCode: string; metadata: Record; @@ -57,11 +64,18 @@ interface EsSource { url: null | string; } +/** The storage key of any item id - exactly what `core_search_index` holds. */ +const itemKeyOf = (itemId: ContentId): string => String(itemId); + +/** + * The document id. A serial item's is exactly what it always was + * (`blog.post:42:en`), so an existing index keeps its documents. + */ const docId = ( itemType: string, - itemId: number, + itemId: ContentId, languageCode: string, -): string => `${itemType}:${itemId}:${languageCode}`; +): string => `${itemType}:${itemKeyOf(itemId)}:${languageCode}`; interface EsErrorBody { error?: { type?: string }; @@ -78,7 +92,8 @@ const toSource = (doc: SearchDocument): EsSource => ({ // reason a mirrored document disagrees with the canonical row. pluginId: doc.pluginId ?? "core", itemType: doc.itemType, - itemId: doc.itemId, + ...(typeof doc.itemId === "number" ? { itemId: doc.itemId } : {}), + itemKey: itemKeyOf(doc.itemId), languageCode: doc.languageCode ?? "", authorIds: [...new Set(doc.authorIds ?? [])], title: doc.title, @@ -205,11 +220,16 @@ const mapHit = (hit: estypes.SearchHit): null | SearchHit => { const source = hit._source; if (!source) return null; + // A document indexed before `itemKey` existed has only the number; a `uuid` + // or `bigint` one has only the key. Numeric ids stay numbers either way. + const itemId: ContentId | undefined = source.itemId ?? source.itemKey; + if (itemId === undefined) return null; + return { - id: source.itemId, + id: typeof itemId === "number" ? itemId : 0, pluginId: source.pluginId, itemType: source.itemType, - itemId: source.itemId, + itemId, languageCode: source.languageCode, authorId: source.authorIds?.[0] ?? null, title: source.title, @@ -255,7 +275,18 @@ export const ElasticsearchSearchAdapter = ( const createIndexIfMissing = async (): Promise => { const es = getClient(); - if (await es.indices.exists({ index })) return; + if (await es.indices.exists({ index })) { + // An index created before `itemKey` existed gains the field. Adding a + // keyword property is a compatible mapping change, so this is safe to + // repeat; documents written before it carry only `itemId`, which every + // read and delete below still honours. Rebuild the index to backfill. + await es.indices.putMapping({ + index, + properties: { itemKey: { type: "keyword" } }, + }); + + return; + } try { await es.indices.create({ @@ -265,6 +296,7 @@ export const ElasticsearchSearchAdapter = ( pluginId: { type: "keyword" }, itemType: { type: "keyword" }, itemId: { type: "integer" }, + itemKey: { type: "keyword" }, languageCode: { type: "keyword" }, authorIds: { type: "integer" }, title: { type: "text" }, @@ -372,6 +404,7 @@ export const ElasticsearchSearchAdapter = ( // named one, which is how a single translation is taken down without // touching the others. delete: async (_c, itemType, itemId, languageCode) => { + await ensureIndex(); await getClient().deleteByQuery( { index, @@ -379,11 +412,17 @@ export const ElasticsearchSearchAdapter = ( bool: { filter: [ { term: { itemType } }, - { term: { itemId } }, ...(languageCode === undefined ? [] : [{ term: { languageCode } }]), ], + // By key, which every document written since carries - or by the + // number, which is all a document indexed before it has. + minimum_should_match: 1, + should: [ + { term: { itemKey: itemKeyOf(itemId) } }, + ...(typeof itemId === "number" ? [{ term: { itemId } }] : []), + ], }, }, }, diff --git a/packages/vitnode/src/api/lib/pagination-cursor.test.ts b/packages/vitnode/src/api/lib/pagination-cursor.test.ts index 289661f2c8..d84c10a332 100644 --- a/packages/vitnode/src/api/lib/pagination-cursor.test.ts +++ b/packages/vitnode/src/api/lib/pagination-cursor.test.ts @@ -28,6 +28,7 @@ import { const probes = camelCase.table("cursor_probes", { big: bigint({ mode: "bigint" }), + bigString: bigint({ mode: "string" }), clock: time(), clockTz: time({ withTimezone: true }), day: date(), @@ -535,3 +536,53 @@ describe("minting keeps the database's own representation", () => { ); }); }); + +describe('a string-mode bigint, which `idStrategy: "bigint"` keys by', () => { + const BEYOND_SAFE = "9007199254740993"; + + it("is an identifier column that keeps its decimal string exact", () => { + expect(isPaginationIdentifierColumn(probes.bigString)).toBe(true); + expect(cursorIdentifierOf(probes.bigString, BEYOND_SAFE)).toBe(BEYOND_SAFE); + expect(cursorIdentifierForColumn(probes.bigString, BEYOND_SAFE)).toBe( + BEYOND_SAFE, + ); + }); + + it("orders as a bigint, binding the canonical string rather than a number", () => { + expect(cursorValueOf(probes.bigString, BEYOND_SAFE)).toBe(BEYOND_SAFE); + expect(cursorValueForColumn(probes.bigString, BEYOND_SAFE)).toBe( + BEYOND_SAFE, + ); + }); + + it("refuses what the column could never hold with a 400", () => { + for (const value of ["1.5", "abc", "9223372036854775808", "-1"]) { + let status = 0; + try { + cursorIdentifierForColumn(probes.bigString, value); + } catch (error) { + status = statusOf(error); + } + + expect([value, status]).toEqual([value, 400]); + } + expect(() => cursorValueForColumn(probes.bigString, "1.5")).toThrow( + HTTPException, + ); + }); + + it("round-trips a cursor without a Number in between", () => { + const cursor = { + column: "bigString", + id: BEYOND_SAFE, + value: BEYOND_SAFE, + }; + + expect( + decodePaginationCursor(encodePaginationCursor(cursor), { + column: "bigString", + primary: probes.bigString, + }), + ).toEqual(cursor); + }); +}); diff --git a/packages/vitnode/src/api/models/search.test.ts b/packages/vitnode/src/api/models/search.test.ts index d02e558bb7..a24b52f0ce 100644 --- a/packages/vitnode/src/api/models/search.test.ts +++ b/packages/vitnode/src/api/models/search.test.ts @@ -6,10 +6,12 @@ import type { RegisteredContentType } from "@/content/registry"; import { core_search_index } from "@/database/search"; import { + testBigintEventContentType, testLocalizedPageContentType, testLocalizedSearchPageContentType, testSearchablePostContentType, testStrictLocalizedSearchPageContentType, + testUuidTagContentType, } from "@/tests/content-fixtures"; import type { SearchDocument, SearchProviderApiPlugin } from "./search"; @@ -18,6 +20,7 @@ import { PostgresSearchAdapter } from "../adapters/search/postgres"; import { assertSearchProviderCapabilities, normalizeSearchIndexerPage, + searchItemIdFromKey, searchLanguageFallbacks, SearchModel, } from "./search"; @@ -482,3 +485,60 @@ describe("SearchModel language fallback", () => { expect(provider.search).toHaveBeenCalledWith(c, { authorId: 1 }); }); }); + +describe("item ids under every strategy", () => { + const BEYOND_SAFE = "9007199254740993"; + const UUID = "0198f6f7-d4a2-7ce1-a2ee-4f5f1f2f3a4b"; + const types = [ + registered(testSearchablePostContentType), + registered(testBigintEventContentType), + registered(testUuidTagContentType), + ]; + + it("stores every id as its key, and hands the provider the id itself", async () => { + const provider = createProvider(); + const { c, values } = createContext(provider); + + await new SearchModel(c).index({ + content: "Body", + createdAt: new Date("2026-01-01"), + itemId: BEYOND_SAFE, + itemType: "test.bigint-event", + title: "Event", + }); + + expect(values.mock.calls[0][0]).toMatchObject({ itemId: BEYOND_SAFE }); + expect(provider.index).toHaveBeenCalledWith( + c, + expect.objectContaining({ itemId: BEYOND_SAFE }), + ); + }); + + it("deletes a uuid record by its key", async () => { + const provider = createProvider(); + const { c } = createContext(provider); + + await new SearchModel(c).delete("test.uuid-tag", UUID, "en"); + + expect(provider.delete).toHaveBeenCalledWith( + c, + "test.uuid-tag", + UUID, + "en", + ); + }); + + it("reads a key back as the id its content type uses", () => { + expect(searchItemIdFromKey(types, "test.searchable", "42")).toBe(42); + expect(searchItemIdFromKey(types, "test.bigint-event", BEYOND_SAFE)).toBe( + BEYOND_SAFE, + ); + expect(searchItemIdFromKey(types, "test.uuid-tag", UUID)).toBe(UUID); + }); + + it("keeps a numeric key a number for an item type that is not a content type", () => { + expect(searchItemIdFromKey(types, "forum.topic", "7")).toBe(7); + expect(searchItemIdFromKey(undefined, "forum.topic", "7")).toBe(7); + expect(searchItemIdFromKey(types, "forum.topic", "abc")).toBe("abc"); + }); +}); diff --git a/packages/vitnode/src/content/admin/route.test.ts b/packages/vitnode/src/content/admin/route.test.ts index 4e17a2626c..e7ac489174 100644 --- a/packages/vitnode/src/content/admin/route.test.ts +++ b/packages/vitnode/src/content/admin/route.test.ts @@ -108,6 +108,56 @@ describe("resolveContentAdminRoute", () => { ).toBeUndefined(); }); + describe("under another id strategy", () => { + const strategyOf = (idStrategy: "bigint" | "uuid") => + defineContentType({ + admin: { edit: { mode: "page" } }, + fields: { title: field.text({ required: true }) }, + id: `test.${idStrategy}`, + idStrategy, + tableName: `test_${idStrategy}`, + }) as AnyContentTypeDefinition; + const uuidType = strategyOf("uuid"); + const bigintType = strategyOf("bigint"); + const UUID = "0198f6f7-d4a2-7ce1-a2ee-4f5f1f2f3a4b"; + + it("reads a uuid record's edit URL as its canonical string", () => { + expect( + resolveContentAdminRoute( + ["test", "uuid", UUID, "edit"], + lookupOf(uuidType), + ), + ).toEqual({ action: "edit", contentTypeId: "test.uuid", itemId: UUID }); + }); + + it("keeps a bigint beyond Number.MAX_SAFE_INTEGER exact", () => { + expect( + resolveContentAdminRoute( + ["test", "bigint", "9007199254740993", "edit"], + lookupOf(bigintType), + ), + ).toEqual({ + action: "edit", + contentTypeId: "test.bigint", + itemId: "9007199254740993", + }); + }); + + it.each([ + ["an uppercase uuid", ["test", "uuid", UUID.toUpperCase(), "edit"]], + ["a number for a uuid", ["test", "uuid", "42", "edit"]], + ["a padded bigint", ["test", "bigint", "01", "edit"]], + [ + "a bigint out of range", + ["test", "bigint", "9223372036854775808", "edit"], + ], + ])("resolves nothing for %s", (_name, slug) => { + expect( + resolveContentAdminRoute(slug, lookupOf(uuidType, bigintType)), + ).toBeUndefined(); + }); + }); + it("prefers an exact content type path over a create page", () => { // `blog/post/create` is a legal address, so the content type that really // lives there keeps its own list screen. diff --git a/packages/vitnode/src/content/id-strategy.test-d.ts b/packages/vitnode/src/content/id-strategy.test-d.ts new file mode 100644 index 0000000000..40497f225d --- /dev/null +++ b/packages/vitnode/src/content/id-strategy.test-d.ts @@ -0,0 +1,204 @@ +import { describe, expectTypeOf, it } from "vitest"; + +import type { testArticleContentType } from "@/tests/content-fixtures"; + +import { + testBigintEventContentType, + testBigintPageContentType, + testCategoryContentType, + testUuidTagContentType, +} from "@/tests/content-fixtures"; + +import type { ContentEventsFor } from "./events"; +import type { ContentIdOf } from "./ids"; +import type { ContentService } from "./server/service"; +import type { + ContentCreateInput, + ContentDetail, + ContentFilterInput, + ContentPublicRelation, + ContentPublicSelect, + ContentRelationTarget, + ContentSelect, + ContentTranslationRow, +} from "./types"; + +import { createContentTable } from "./server/table"; +import { createContentTranslationTable } from "./server/translation-table"; + +type Tag = typeof testUuidTagContentType; +type Event = typeof testBigintEventContentType; +type Page = typeof testBigintPageContentType; + +describe("ContentIdOf", () => { + it("is a number for serial and a string for uuid and bigint", () => { + expectTypeOf< + ContentIdOf + >().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + }); +}); + +describe("rows", () => { + it("types `id` by the content type's own strategy", () => { + expectTypeOf["id"]>().toEqualTypeOf(); + expectTypeOf["id"]>().toEqualTypeOf(); + expectTypeOf< + ContentSelect["id"] + >().toEqualTypeOf(); + }); + + it("types a to-one relation by its target's strategy", () => { + expectTypeOf["primaryTag"]>().toEqualTypeOf< + null | string + >(); + expectTypeOf["category"]>().toEqualTypeOf< + null | number + >(); + expectTypeOf["category"]>().toEqualTypeOf< + null | number + >(); + }); + + it("types a to-many relation by its target's strategy", () => { + expectTypeOf["tags"]>().toEqualTypeOf(); + expectTypeOf["tags"]>().toEqualTypeOf< + string[] | undefined + >(); + }); + + it("re-points a self-relation at the owner's strategy", () => { + expectTypeOf["relatedEvents"]>().toEqualTypeOf< + string[] + >(); + expectTypeOf["relatedTags"]>().toEqualTypeOf(); + }); + + it("keeps a repeatable child's own id a number", () => { + expectTypeOf< + ContentDetail["sessions"][number]["id"] + >().toEqualTypeOf(); + }); + + it("types a translation's itemId by the owner's strategy", () => { + expectTypeOf< + ContentTranslationRow["itemId"] + >().toEqualTypeOf(); + expectTypeOf< + ContentTranslationRow["itemId"] + >().toEqualTypeOf(); + }); + + it("types a membership filter by the target's strategy", () => { + expectTypeOf["tags"]>().toEqualTypeOf< + undefined | { contains: string } + >(); + }); +}); + +describe("the public projection", () => { + it("carries string ids for uuid and bigint", () => { + expectTypeOf["id"]>().toEqualTypeOf(); + expectTypeOf< + ContentPublicSelect["primaryTag"] + >().toEqualTypeOf | null>(); + expectTypeOf< + ContentPublicSelect["category"] + >().toEqualTypeOf(); + expectTypeOf["tags"]>().toEqualTypeOf< + string[] + >(); + }); +}); + +describe("services", () => { + it("take the content type's own id", () => { + expectTypeOf< + Parameters["findById"]>[0] + >().toEqualTypeOf(); + expectTypeOf< + Parameters["findById"]>[0] + >().toEqualTypeOf(); + }); + + it("type a collection's targets by their own strategy", () => { + type Tags = ContentService["relations"]["tags"]; + + expectTypeOf[0]>().toEqualTypeOf(); + expectTypeOf[1]>().toEqualTypeOf(); + expectTypeOf>>().toEqualTypeOf(); + }); +}); + +describe("events", () => { + it("carry the content type's own id", () => { + type Events = ContentEventsFor; + + expectTypeOf< + Events["content.test.bigint-event.created"]["contentId"] + >().toEqualTypeOf(); + expectTypeOf< + Events["content.test.bigint-event.hidden"]["contentId"] + >().toEqualTypeOf(); + expectTypeOf< + ContentEventsFor< + typeof testArticleContentType + >["content.test.article.created"]["contentId"] + >().toEqualTypeOf(); + }); +}); + +describe("generated tables", () => { + const categories = createContentTable(testCategoryContentType); + const tags = createContentTable(testUuidTagContentType, { + references: { category: () => categories.id }, + }); + const _events = createContentTable(testBigintEventContentType, { + references: { + category: () => categories.id, + primaryTag: () => tags.id, + tags: () => tags.id, + }, + }); + const pages = createContentTable(testBigintPageContentType); + const _pageTranslations = createContentTranslationTable( + testBigintPageContentType, + { table: pages }, + ); + + it("infer their select rows from the same strategies", () => { + expectTypeOf<(typeof tags)["$inferSelect"]["id"]>().toEqualTypeOf(); + expectTypeOf< + (typeof _events)["$inferSelect"]["id"] + >().toEqualTypeOf(); + expectTypeOf< + (typeof _events)["$inferSelect"]["primaryTag"] + >().toEqualTypeOf(); + expectTypeOf<(typeof _events)["$inferSelect"]["category"]>().toEqualTypeOf< + null | number + >(); + expectTypeOf< + (typeof _pageTranslations)["$inferSelect"]["itemId"] + >().toEqualTypeOf(); + }); + + it("generate the id, so an insert may leave it out", () => { + expectTypeOf< + undefined extends (typeof tags)["$inferInsert"]["id"] ? true : false + >().toEqualTypeOf(); + expectTypeOf< + undefined extends (typeof _events)["$inferInsert"]["id"] ? true : false + >().toEqualTypeOf(); + }); +}); + +describe("relation targets", () => { + it("infer serial for every relation written before strategies existed", () => { + type Category = (typeof testArticleContentType)["fields"]["category"]; + + expectTypeOf>().toEqualTypeOf< + ContentRelationTarget<"serial"> + >(); + }); +}); diff --git a/packages/vitnode/src/content/ids.test.ts b/packages/vitnode/src/content/ids.test.ts new file mode 100644 index 0000000000..40f47bd2ad --- /dev/null +++ b/packages/vitnode/src/content/ids.test.ts @@ -0,0 +1,209 @@ +import { describe, expect, it } from "vitest"; + +import { + testArticleContentType, + testBigintEventContentType, + testUuidTagContentType, +} from "@/tests/content-fixtures"; + +import { ContentEngineError } from "./errors"; +import { + compareContentIds, + CONTENT_BIGINT_MAX, + CONTENT_SERIAL_MAX, + contentIdFromKey, + contentIdKey, + contentIdParamSchema, + contentIdSchema, + contentIdsOf, + contentRelationStrategy, + isContentId, + parseContentId, + requireContentId, +} from "./ids"; + +const UUID = "0198f6f7-d4a2-7ce1-a2ee-4f5f1f2f3a4b"; +const BEYOND_SAFE = "9007199254740993"; + +describe("parseContentId", () => { + describe("serial", () => { + it("reads a positive integer as a number, from a number or its digits", () => { + expect(parseContentId("serial", 42)).toBe(42); + expect(parseContentId("serial", "42")).toBe(42); + expect(parseContentId("serial", CONTENT_SERIAL_MAX)).toBe( + CONTENT_SERIAL_MAX, + ); + }); + + it.each([0, -1, 1.5, CONTENT_SERIAL_MAX + 1, "007", "1e3", " 1", "", null])( + "refuses %o", + value => { + expect(parseContentId("serial", value)).toBeNull(); + }, + ); + }); + + describe("bigint", () => { + it("keeps a value beyond Number.MAX_SAFE_INTEGER as its exact digits", () => { + expect(parseContentId("bigint", BEYOND_SAFE)).toBe(BEYOND_SAFE); + expect(parseContentId("bigint", CONTENT_BIGINT_MAX)).toBe( + CONTENT_BIGINT_MAX, + ); + expect(parseContentId("bigint", 9_007_199_254_740_993n)).toBe( + BEYOND_SAFE, + ); + }); + + it("accepts a safe JavaScript number, and refuses one that was already rounded", () => { + expect(parseContentId("bigint", 42)).toBe("42"); + expect(parseContentId("bigint", 2 ** 53)).toBeNull(); + }); + + it.each(["9223372036854775808", "0", "-1", "01", "1.5", "abc", ""])( + "refuses %o", + value => { + expect(parseContentId("bigint", value)).toBeNull(); + }, + ); + }); + + describe("uuid", () => { + it("accepts the canonical lowercase spelling only", () => { + expect(parseContentId("uuid", UUID)).toBe(UUID); + expect(parseContentId("uuid", UUID.toUpperCase())).toBeNull(); + expect(parseContentId("uuid", `{${UUID}}`)).toBeNull(); + expect(parseContentId("uuid", UUID.replaceAll("-", ""))).toBeNull(); + expect(parseContentId("uuid", 42)).toBeNull(); + }); + }); +}); + +describe("storage keys", () => { + it("keep a serial record's key exactly the digits it always had", () => { + expect(contentIdKey(42)).toBe("42"); + expect(contentIdFromKey("serial", "42")).toBe(42); + }); + + it("round-trip every strategy through its key", () => { + expect(contentIdFromKey("bigint", contentIdKey(BEYOND_SAFE))).toBe( + BEYOND_SAFE, + ); + expect(contentIdFromKey("uuid", contentIdKey(UUID))).toBe(UUID); + }); + + it("read nothing out of a key the strategy cannot hold", () => { + expect(contentIdFromKey("serial", UUID)).toBeNull(); + expect(contentIdFromKey("uuid", "42")).toBeNull(); + }); +}); + +describe("isContentId", () => { + it("is true only for the strategy's own representation", () => { + expect(isContentId("serial", 7)).toBe(true); + expect(isContentId("serial", "7")).toBe(false); + expect(isContentId("bigint", "7")).toBe(true); + expect(isContentId("bigint", 7)).toBe(false); + expect(isContentId("uuid", UUID)).toBe(true); + }); +}); + +describe("requireContentId", () => { + it("returns the canonical id", () => { + expect(requireContentId("bigint", BEYOND_SAFE)).toBe(BEYOND_SAFE); + }); + + it("throws rather than handing back a sentinel", () => { + expect(() => requireContentId("uuid", 0, "test.tag")).toThrow( + ContentEngineError, + ); + }); +}); + +describe("contentIdsOf", () => { + it("keeps the readable ids, canonicalised, in order", () => { + expect(contentIdsOf("serial", [3, "1", "x", 2.5])).toEqual([3, 1]); + expect(contentIdsOf("uuid", [UUID, "nope"])).toEqual([UUID]); + }); +}); + +describe("compareContentIds", () => { + it("orders bigints numerically without losing precision", () => { + expect(compareContentIds("bigint", "10", "9")).toBeGreaterThan(0); + expect(compareContentIds("bigint", BEYOND_SAFE, "9007199254740992")).toBe( + 1, + ); + }); + + it("orders uuids lexically and serials numerically", () => { + expect(compareContentIds("uuid", "a", "b")).toBe(-1); + expect(compareContentIds("serial", 10, 9)).toBeGreaterThan(0); + }); +}); + +describe("schemas", () => { + it("validate a JSON id per strategy", () => { + expect(contentIdSchema("serial").safeParse(7).success).toBe(true); + expect(contentIdSchema("serial").safeParse("7").success).toBe(false); + expect(contentIdSchema("bigint").safeParse(BEYOND_SAFE).success).toBe(true); + expect(contentIdSchema("bigint").safeParse(7).success).toBe(false); + expect(contentIdSchema("uuid").safeParse(UUID.toUpperCase()).success).toBe( + false, + ); + }); + + it("parse a path or query value into the strategy's representation", () => { + expect(contentIdParamSchema("serial").parse("7")).toBe(7); + expect(contentIdParamSchema("bigint").parse(BEYOND_SAFE)).toBe(BEYOND_SAFE); + expect(contentIdParamSchema("uuid").safeParse("7").success).toBe(false); + }); + + it("keep a serial content type's `{id}` the coerced number it always was", () => { + expect(testArticleContentType.schemas.params.parse({ id: "7" })).toEqual({ + id: 7, + }); + }); + + it("refuse a non-canonical `{id}` on a uuid or bigint content type", () => { + const { params: tagParams } = testUuidTagContentType.schemas; + const { params: eventParams } = testBigintEventContentType.schemas; + + expect(tagParams.safeParse({ id: UUID }).success).toBe(true); + expect(tagParams.safeParse({ id: UUID.toUpperCase() }).success).toBe(false); + expect(eventParams.parse({ id: BEYOND_SAFE })).toEqual({ id: BEYOND_SAFE }); + expect(eventParams.safeParse({ id: "01" }).success).toBe(false); + }); + + it("type relation values by the target's strategy, a self-relation by the owner's", () => { + const { create } = testBigintEventContentType.schemas; + + expect( + create.safeParse({ + primaryTag: UUID, + relatedEvents: [BEYOND_SAFE], + tags: [UUID], + title: "Valid", + }).success, + ).toBe(true); + expect(create.safeParse({ primaryTag: 7, title: "x" }).success).toBe(false); + expect(create.safeParse({ relatedEvents: [7], title: "x" }).success).toBe( + false, + ); + expect(create.safeParse({ category: 7, title: "x" }).success).toBe(true); + }); +}); + +describe("contentRelationStrategy", () => { + it("is the target's strategy, or the owner's for a self-relation", () => { + const { fields } = testBigintEventContentType; + + expect( + contentRelationStrategy(testBigintEventContentType, fields.primaryTag), + ).toBe("uuid"); + expect( + contentRelationStrategy(testBigintEventContentType, fields.category), + ).toBe("serial"); + expect( + contentRelationStrategy(testBigintEventContentType, fields.relatedEvents), + ).toBe("bigint"); + }); +}); diff --git a/packages/vitnode/src/content/server/preview-token.test.ts b/packages/vitnode/src/content/server/preview-token.test.ts index 65f7cce336..7725fd9956 100644 --- a/packages/vitnode/src/content/server/preview-token.test.ts +++ b/packages/vitnode/src/content/server/preview-token.test.ts @@ -1,8 +1,10 @@ import { describe, expect, it } from "vitest"; import { + testBigintEventContentType, testEditorialNoteContentType, testEditorialPostContentType, + testUuidTagContentType, } from "../../tests/content-fixtures"; import { createContentPreviewToken, @@ -144,3 +146,46 @@ describe("verifyContentPreviewToken", () => { expect(failures).toEqual([null, null, null, null, null]); }); }); + +describe("tokens for every id strategy", () => { + const BEYOND_SAFE = "9007199254740993"; + const UUID = "0198f6f7-d4a2-7ce1-a2ee-4f5f1f2f3a4b"; + + const round = ( + definition: + typeof testBigintEventContentType | typeof testUuidTagContentType, + itemId: number | string, + ) => + verifyContentPreviewToken({ + definition, + now: NOW, + pluginId: PLUGIN, + secret: SECRET, + token: createContentPreviewToken({ + definition, + itemId, + now: NOW, + pluginId: PLUGIN, + revisionId: 1, + secret: SECRET, + version: 1, + }).token, + }); + + it("carries a bigint beyond Number.MAX_SAFE_INTEGER as its exact digits", () => { + expect(round(testBigintEventContentType, BEYOND_SAFE)?.i).toBe(BEYOND_SAFE); + }); + + it("carries a uuid as its canonical spelling", () => { + expect(round(testUuidTagContentType, UUID)?.i).toBe(UUID); + }); + + it("refuses a signed id the content type's strategy cannot read", () => { + expect(round(testUuidTagContentType, 7)).toBeNull(); + expect(round(testBigintEventContentType, UUID)).toBeNull(); + }); + + it("still verifies a serial token whose id is a number, as every token minted before was", () => { + expect(verify(mint({ itemId: 7 }).token)?.i).toBe(7); + }); +}); diff --git a/packages/vitnode/src/content/server/search-document.test.ts b/packages/vitnode/src/content/server/search-document.test.ts index 09bb551bd6..2f5563703a 100644 --- a/packages/vitnode/src/content/server/search-document.test.ts +++ b/packages/vitnode/src/content/server/search-document.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { + testBigintEventContentType, testContentLocaleRouting, testPostContentType, testSearchablePostContentType, @@ -215,3 +216,34 @@ describe("content search document", () => { }); }); }); + +describe("a content type keyed by another id strategy", () => { + const event = (id: unknown) => + contentSearchDocument( + testBigintEventContentType, + { + createdAt: CREATED_AT, + id, + publishedAt: PUBLISHED_AT, + sessions: [], + slug: "launch", + status: "published", + title: "Launch", + updatedAt: UPDATED_AT, + }, + { routing }, + ); + + it("indexes a bigint record under its exact string id, never skipping it", () => { + expect(event("9007199254740993")).toMatchObject({ + itemId: "9007199254740993", + itemType: "test.bigint-event", + url: "/bigint-events/launch", + }); + }); + + it("builds nothing for an id the strategy cannot read", () => { + expect(event(7)).toBeNull(); + expect(event("not-an-id")).toBeNull(); + }); +}); diff --git a/packages/vitnode/src/content/server/table.test.ts b/packages/vitnode/src/content/server/table.test.ts index 33b19e02d8..d04d482b40 100644 --- a/packages/vitnode/src/content/server/table.test.ts +++ b/packages/vitnode/src/content/server/table.test.ts @@ -122,6 +122,71 @@ describe("createContentTable", () => { ).not.toThrow(); }); + describe("across id strategies", () => { + // The same table name with a `uuid` key, so only the key type can differ. + const uuidCategory = defineContentType({ + fields: { title: field.text({ required: true }) }, + id: "test.uuid-category", + idStrategy: "uuid", + tableName: "test_categories", + }); + const pointing = ( + target: () => AnyContentTypeDefinition, + ): AnyContentTypeDefinition => + defineContentType({ + fields: { category: field.relation({ required: true, target }) }, + id: "test.pointing", + tableName: "test_pointing", + }); + + it("builds the foreign key with the type of the key it points at", () => { + const uuidCategories = createContentTable(uuidCategory); + const table = createContentTable( + pointing(() => uuidCategory) as typeof testArticleContentType, + { references: { category: () => uuidCategories.id } }, + ); + + expect( + getTableConfig(table) + .columns.find(item => item.name === "category") + ?.getSQLType(), + ).toBe("uuid"); + expect(() => assertContentReferences(table)).not.toThrow(); + }); + + it("names the field when the referenced key has another type", () => { + // The descriptor says the target is keyed by `uuid`, while the table + // handed to `references` is the serial one: a column built as `uuid` + // would point at an `integer`. + const table = createContentTable( + pointing(() => uuidCategory) as typeof testArticleContentType, + { references: { category: () => categories.id } }, + ); + + expect(() => assertContentReferences(table)).toThrow( + /"category".*idStrategy: "uuid".*`serial`/s, + ); + }); + + it("falls back to serial for a target it cannot read yet, and says so when that was wrong", () => { + const uuidCategories = createContentTable(uuidCategory); + let loaded = false; + const table = createContentTable( + pointing(() => { + if (!loaded) throw new Error("still loading"); + + return uuidCategory; + }) as typeof testArticleContentType, + { references: { category: () => uuidCategories.id } }, + ); + loaded = true; + + expect(() => assertContentReferences(table)).toThrow( + /"category".*idStrategy: "serial".*`uuid`/s, + ); + }); + }); + it("rejects a reference for a field that is not a relation", () => { // A content type with no relations has an empty `references` type, which // does not trip excess-property checking, so the runtime guard is the diff --git a/packages/vitnode/src/content/server/types.ts b/packages/vitnode/src/content/server/types.ts index 7b9e41e962..84e2f5c1dd 100644 --- a/packages/vitnode/src/content/server/types.ts +++ b/packages/vitnode/src/content/server/types.ts @@ -15,6 +15,7 @@ import type { PgUUIDBuilder, PgVarcharBuilder, SetHasDefault, + SetIsPrimaryKey, SetNotNull, } from "drizzle-orm/pg-core"; @@ -45,9 +46,9 @@ type EnumValuesOf = TField extends { /** The primary key column of a content table, per id strategy. */ export type ContentIdPkBuilder = TStrategy extends "uuid" - ? SetHasDefault + ? SetIsPrimaryKey> : TStrategy extends "bigint" - ? HasIdentity + ? HasIdentity, "byDefault"> : PgSerialBuilder; /** A foreign key column pointing at a content table with `TStrategy` ids. */ diff --git a/packages/vitnode/src/tests/content-fixtures.ts b/packages/vitnode/src/tests/content-fixtures.ts index fbb079bf47..89c770ab3a 100644 --- a/packages/vitnode/src/tests/content-fixtures.ts +++ b/packages/vitnode/src/tests/content-fixtures.ts @@ -790,3 +790,141 @@ export const testHideableNoteContentType = defineContentType({ pathTemplate: "/hideable-notes/{slug}", }, }); + +/** + * `idStrategy: "uuid"`, localized: the translation table's `itemId` is a `uuid`, + * a to-one relation points at a *serial* content type and a self-relation + * follows the owner's own strategy. + */ +export const testUuidTagContentType = defineContentType({ + id: "test.uuid-tag", + tableName: "test_uuid_tags", + idStrategy: "uuid", + localization: { defaultLocale: "en", fallback: "default" }, + publication: true, + visibility: true, + duplication: true, + editorial: { + preview: { expiresInMinutes: 30 }, + }, + fields: { + name: field.text({ localized: true, required: true, maxLength: 100 }), + slug: field.slug({ localized: true, source: "name" }), + category: field.relation({ + nullable: true, + onDelete: "set null", + target: () => testCategoryContentType, + }), + relatedTags: field.relation({ multiple: true, ordered: true, self: true }), + }, + publicApi: { + path: "uuid-tags", + fields: ["id", "name", "slug", "category", "relatedTags", "publishedAt"], + filterableFields: ["relatedTags"], + defaultOrderBy: "publishedAt", + }, + admin: { titleField: "name" }, +}); + +/** + * `idStrategy: "bigint"`, the fullest non-localized shape: every relation kind + * across strategies (a `uuid` target, a `serial` one, a bigint self-relation), + * a repeatable whose children stay `serial`, editorial history with previews and + * scheduling, search and delivery with redirects. + */ +export const testBigintEventContentType = defineContentType({ + id: "test.bigint-event", + tableName: "test_bigint_events", + idStrategy: "bigint", + publication: true, + visibility: true, + duplication: true, + editorial: { + preview: { expiresInMinutes: 30 }, + scheduling: true, + }, + fields: { + title: field.text({ required: true, maxLength: 200 }), + slug: field.slug({ source: "title" }), + body: field.richText({ nullable: true }), + primaryTag: field.relation({ + nullable: true, + onDelete: "set null", + target: () => testUuidTagContentType, + }), + category: field.relation({ + nullable: true, + onDelete: "set null", + target: () => testCategoryContentType, + }), + tags: field.relation({ + multiple: true, + ordered: true, + target: () => testUuidTagContentType, + }), + relatedEvents: field.relation({ multiple: true, self: true }), + sessions: field.repeatable({ + fields: { + title: field.text({ required: true, maxLength: 100 }), + startsAt: field.dateTime({ nullable: true }), + }, + }), + }, + publicApi: { + path: "bigint-events", + fields: [ + "id", + "title", + "slug", + "primaryTag", + "category", + "tags", + "relatedEvents", + "sessions.title", + "publishedAt", + ], + filterableFields: ["tags"], + defaultOrderBy: "publishedAt", + }, + search: { + titleField: "title", + contentFields: ["title", "sessions.title"], + pathTemplate: "/bigint-events/{slug}", + }, + delivery: { + redirects: true, + seo: { titleField: "title" }, + sitemap: true, + }, + admin: { titleField: "title", list: { columns: ["title", "tags"] } }, +}); + +/** `idStrategy: "bigint"` with translations: a `bigint` translation `itemId`. */ +export const testBigintPageContentType = defineContentType({ + id: "test.bigint-page", + tableName: "test_bigint_pages", + idStrategy: "bigint", + localization: { defaultLocale: "en", fallback: "default" }, + publication: true, + editorial: true, + fields: { + title: field.text({ localized: true, required: true, maxLength: 200 }), + slug: field.slug({ localized: true, source: "title" }), + }, + publicApi: { + path: "bigint-pages", + fields: ["id", "title", "slug", "publishedAt"], + defaultOrderBy: "publishedAt", + }, + search: { + titleField: "title", + contentFields: ["title"], + pathTemplate: "/{locale}/bigint-pages/{slug}", + }, + delivery: { + redirects: true, + hreflang: { xDefault: "defaultLocale" }, + seo: { titleField: "title" }, + sitemap: true, + }, +}); diff --git a/plugins/example/src/admin/content.tsx b/plugins/example/src/admin/content.tsx index 915c7441be..5105304f71 100644 --- a/plugins/example/src/admin/content.tsx +++ b/plugins/example/src/admin/content.tsx @@ -2,9 +2,21 @@ import type { ContentFrontendPluginSource } from "@vitnode/core/lib/plugin"; import { CONFIG_PLUGIN } from "@/const"; -import { exampleArticleNav, exampleCategoryNav, examplePageNav } from "./nav"; +import { + exampleArticleNav, + exampleCategoryNav, + exampleEventNav, + examplePageNav, + exampleTagNav, +} from "./nav"; export const adminContent = { pluginId: CONFIG_PLUGIN.pluginId, - contentTypes: [exampleArticleNav, exampleCategoryNav, examplePageNav], + contentTypes: [ + exampleArticleNav, + exampleCategoryNav, + examplePageNav, + exampleTagNav, + exampleEventNav, + ], } satisfies ContentFrontendPluginSource; diff --git a/plugins/example/src/admin/nav.tsx b/plugins/example/src/admin/nav.tsx index 06ad4ebdfd..fe092e3017 100644 --- a/plugins/example/src/admin/nav.tsx +++ b/plugins/example/src/admin/nav.tsx @@ -1,16 +1,20 @@ import type { AdminNavPluginSource } from "@vitnode/core/lib/plugin"; import { + CalendarDaysIcon, FlaskConicalIcon, FolderIcon, LayoutTemplateIcon, NotebookPenIcon, + TagIcon, } from "lucide-react"; import { CONFIG_PLUGIN } from "@/const"; import { articleContentType } from "@/content/article"; import { categoryContentType } from "@/content/category"; +import { eventContentType } from "@/content/event"; import { pageContentType } from "@/content/page"; +import { tagContentType } from "@/content/tag"; /** The article content type, as the sidebar reads it. */ export const exampleArticleNav = { @@ -29,9 +33,27 @@ export const examplePageNav = { icon: , }; +/** Tags are keyed by `uuid`: their edit URLs carry a lowercase uuid. */ +export const exampleTagNav = { + definition: tagContentType, + icon: , +}; + +/** Events are keyed by `bigint`: their ids stay exact decimal strings. */ +export const exampleEventNav = { + definition: eventContentType, + icon: , +}; + export const adminNav = { pluginId: CONFIG_PLUGIN.pluginId, - contentTypes: [exampleArticleNav, exampleCategoryNav, examplePageNav], + contentTypes: [ + exampleArticleNav, + exampleCategoryNav, + examplePageNav, + exampleTagNav, + exampleEventNav, + ], admin: { nav: [ { diff --git a/plugins/example/src/api/modules/admin/admin.module.ts b/plugins/example/src/api/modules/admin/admin.module.ts index ae316690a3..43c56728c4 100644 --- a/plugins/example/src/api/modules/admin/admin.module.ts +++ b/plugins/example/src/api/modules/admin/admin.module.ts @@ -5,8 +5,10 @@ import { CONFIG_PLUGIN } from "@/const"; import { advancedArticleContent } from "@/database/advanced-articles"; import { articleContent } from "@/database/articles"; import { categoryContent } from "@/database/categories"; +import { eventContent } from "@/database/events"; import { localizedArticleContent } from "@/database/localized-articles"; import { pageContent } from "@/database/pages"; +import { tagContent } from "@/database/tags"; export const adminModule = buildModule({ pluginId: CONFIG_PLUGIN.pluginId, @@ -25,8 +27,11 @@ export const adminModule = buildModule({ advancedArticleContent, articleContent, categoryContent, + // Keyed by `bigint` and `uuid`: their routes take `{id}` as a string. + eventContent, localizedArticleContent, pageContent, + tagContent, ], }), ], diff --git a/plugins/example/src/config.api.ts b/plugins/example/src/config.api.ts index 9261faa00d..777f0e63e1 100644 --- a/plugins/example/src/config.api.ts +++ b/plugins/example/src/config.api.ts @@ -10,8 +10,10 @@ import { settingsPage } from "@/content/settings-page"; import { advancedArticleContent } from "@/database/advanced-articles"; import { articleContent } from "@/database/articles"; import { categoryContent } from "@/database/categories"; +import { eventContent } from "@/database/events"; import { localizedArticleContent } from "@/database/localized-articles"; import { pageContent } from "@/database/pages"; +import { tagContent } from "@/database/tags"; import { widgets } from "./widgets"; import "@/api/lib/events"; @@ -36,8 +38,10 @@ export const exampleApiPlugin = () => advancedArticleContent, articleContent, categoryContent, + eventContent, localizedArticleContent, pageContent, + tagContent, ], }), ], diff --git a/plugins/example/src/content.ts b/plugins/example/src/content.ts index a7ebad03ec..e1a73ac91d 100644 --- a/plugins/example/src/content.ts +++ b/plugins/example/src/content.ts @@ -1,8 +1,10 @@ import { advancedArticleContentType } from "@/content/advanced-article"; import { articleContentType } from "@/content/article"; import { categoryContentType } from "@/content/category"; +import { eventContentType } from "@/content/event"; import { localizedArticleContentType } from "@/content/localized-article"; import { pageContentType } from "@/content/page"; +import { tagContentType } from "@/content/tag"; export const contentTypes = [ articleContentType, @@ -10,4 +12,6 @@ export const contentTypes = [ localizedArticleContentType, categoryContentType, pageContentType, + tagContentType, + eventContentType, ]; diff --git a/plugins/example/src/content/advanced-article.ts b/plugins/example/src/content/advanced-article.ts index 2eb312874e..ba3a3bc2ff 100644 --- a/plugins/example/src/content/advanced-article.ts +++ b/plugins/example/src/content/advanced-article.ts @@ -1,6 +1,7 @@ import { defineContentType, field } from "@vitnode/core/content"; import { categoryContentType } from "./category"; +import { tagContentType } from "./tag"; export const advancedArticleContentType = defineContentType({ id: "example.advanced-article", @@ -37,6 +38,14 @@ export const advancedArticleContentType = defineContentType({ target: () => categoryContentType, }), + // A serial record pointing at `uuid` ones: the junction's `relatedItemId` + // is a `uuid` column, and the values are uuid strings in every API. + tags: field.relation({ + multiple: true, + ordered: true, + target: () => tagContentType, + }), + // A self-relation, and an ordered one: "read next" is a sequence somebody // chose. `onDelete: "cascade"` drops the reference when the target article // goes, which is the honest analogue of nulling a column here. @@ -152,7 +161,7 @@ export const advancedArticleContentType = defineContentType({ form: { // The form *does* carry them: this is the surface where a group renders as // a section and a collection as an editor. - fields: ["categories", "relatedArticles", "syndication", "faq"], + fields: ["categories", "tags", "relatedArticles", "syndication", "faq"], }, }, }); diff --git a/plugins/example/src/content/event.ts b/plugins/example/src/content/event.ts new file mode 100644 index 0000000000..576f726872 --- /dev/null +++ b/plugins/example/src/content/event.ts @@ -0,0 +1,131 @@ +import type { ContentRelationTarget } from "@vitnode/core/content"; + +import { defineContentType, field } from "@vitnode/core/content"; + +import { categoryContentType } from "./category"; +import { tagContentType } from "./tag"; + +/** + * An event keyed by `bigint`: a Postgres identity column read in Drizzle's + * `string` mode, so an id beyond `Number.MAX_SAFE_INTEGER` crosses every API as + * its exact decimal string rather than a rounded number. + * + * Its relations cover every combination a mixed install has: a `bigint` record + * pointing at a `uuid` one (`primaryTag`, `tags`), at a `serial` one + * (`category`) and at itself (`relatedEvents`, a bigint self-relation). The + * repeatable's children keep `serial` ids of their own whatever the owner uses. + */ +export const eventContentType = defineContentType({ + id: "example.event", + tableName: "example_events", + idStrategy: "bigint", + + localization: { defaultLocale: "en", fallback: "default" }, + publication: true, + visibility: true, + duplication: true, + editorial: { + revisions: { retention: 20 }, + preview: { expiresInMinutes: 30 }, + scheduling: true, + }, + + fields: { + title: field.text({ + localized: true, + required: true, + minLength: 3, + maxLength: 200, + }), + slug: field.slug({ localized: true, source: "title" }), + body: field.richText({ localized: true, nullable: true }), + + // The annotation keeps the target's strategy in the type even if the two + // modules ever import each other: a mutual relation needs one, exactly as a + // circular `const` does. + primaryTag: field.relation({ + nullable: true, + onDelete: "set null", + target: (): ContentRelationTarget<"uuid"> => tagContentType, + }), + category: field.relation({ + nullable: true, + onDelete: "set null", + target: () => categoryContentType, + }), + tags: field.relation({ + multiple: true, + ordered: true, + target: () => tagContentType, + }), + relatedEvents: field.relation({ + multiple: true, + onDelete: "cascade", + self: true, + }), + + sessions: field.repeatable({ + max: 20, + fields: { + title: field.text({ required: true, minLength: 1, maxLength: 200 }), + startsAt: field.dateTime({ nullable: true }), + }, + }), + gallery: field.file({ + allowedMimeTypes: ["image/jpeg", "image/png", "image/webp"], + maxBytes: 5 * 1024 * 1024, + multiple: true, + }), + }, + + publicApi: { + path: "events", + fields: [ + "id", + "title", + "slug", + "body", + "primaryTag", + "category", + "tags", + "relatedEvents", + "sessions.title", + "sessions.startsAt", + "gallery", + "publishedAt", + ], + filterableFields: ["tags", "category"], + defaultOrderBy: "publishedAt", + }, + + search: { + titleField: "title", + contentFields: ["title", "body", "sessions.title"], + pathTemplate: "/events/{slug}", + }, + + delivery: { + redirects: true, + hreflang: { xDefault: "defaultLocale" }, + seo: { titleField: "title", descriptionField: "body" }, + sitemap: { changeFrequency: "weekly" }, + }, + + admin: { + titleField: "title", + list: { columns: ["status", "updatedAt"] }, + form: { + fields: [ + "title", + "slug", + "body", + "primaryTag", + "category", + "tags", + "relatedEvents", + "sessions", + "gallery", + ], + }, + }, +}); diff --git a/plugins/example/src/content/tag.ts b/plugins/example/src/content/tag.ts new file mode 100644 index 0000000000..3e3bce6261 --- /dev/null +++ b/plugins/example/src/content/tag.ts @@ -0,0 +1,66 @@ +import { defineContentType, field } from "@vitnode/core/content"; + +import { categoryContentType } from "./category"; + +/** + * A tag keyed by `uuid` rather than `serial`: ids are generated by Postgres + * (`gen_random_uuid()`), cross every JSON-facing API as lowercase strings, and + * reveal nothing about how many tags exist or in which order they were made. + * + * Everything else about it is an ordinary localized content type - and its + * relations show both directions of a mixed install: `category` points at the + * *serial* category type, while `relatedTags` is a self-relation and so follows + * the tag's own `uuid` strategy. + */ +export const tagContentType = defineContentType({ + id: "example.tag", + tableName: "example_tags", + idStrategy: "uuid", + + localization: { defaultLocale: "en", fallback: "default" }, + publication: true, + visibility: true, + duplication: true, + editorial: { + revisions: { retention: 20 }, + preview: { expiresInMinutes: 30 }, + }, + + fields: { + name: field.text({ + localized: true, + required: true, + minLength: 1, + maxLength: 100, + }), + slug: field.slug({ localized: true, source: "name" }), + + // A uuid record pointing at a serial one: the column is an `integer`, + // because a foreign key always has the type of the key it points at. + category: field.relation({ + nullable: true, + onDelete: "set null", + target: () => categoryContentType, + }), + + relatedTags: field.relation({ + multiple: true, + onDelete: "cascade", + ordered: true, + self: true, + }), + }, + + publicApi: { + path: "tags", + fields: ["id", "name", "slug", "category", "relatedTags", "publishedAt"], + filterableFields: ["relatedTags"], + defaultOrderBy: "publishedAt", + }, + + admin: { + titleField: "name", + list: { columns: ["status", "updatedAt"] }, + form: { fields: ["name", "slug", "category", "relatedTags"] }, + }, +}); diff --git a/plugins/example/src/database/advanced-articles.ts b/plugins/example/src/database/advanced-articles.ts index dfce66d13d..85346196ea 100644 --- a/plugins/example/src/database/advanced-articles.ts +++ b/plugins/example/src/database/advanced-articles.ts @@ -3,6 +3,7 @@ import { createContentModel } from "@vitnode/core/content/server"; import { advancedArticleContentType } from "@/content/advanced-article"; import { example_categories } from "./categories"; +import { example_tags } from "./tags"; export const advancedArticleContent = createContentModel( advancedArticleContentType, @@ -13,6 +14,7 @@ export const advancedArticleContent = createContentModel( // rather than a column on the row, but the target is just as much a fact // this module has to supply. categories: () => example_categories.id, + tags: () => example_tags.id, // `relatedArticles` is deliberately absent. It is a `self: true` // relation, and the engine resolves it from the table it is building: // writing `() => advancedArticleContent.table.id` here would reference @@ -23,7 +25,7 @@ export const advancedArticleContent = createContentModel( }, ); -// Five exports, not one. Drizzle Kit discovers each table from its export when +// Six exports, not one. Drizzle Kit discovers each table from its export when // it globs the built `dist/src/database/*.js`, so a junction or child table // without one would simply be missing from the migration. export const example_advanced_articles = advancedArticleContent.table; @@ -31,6 +33,8 @@ export const example_advanced_articles_translations = advancedArticleContent.translationTable; export const example_advanced_articles_categories = advancedArticleContent.advancedTables.junctions.categories; +export const example_advanced_articles_tags = + advancedArticleContent.advancedTables.junctions.tags; export const example_advanced_articles_related_articles = advancedArticleContent.advancedTables.junctions.relatedArticles; export const example_advanced_articles_faq = diff --git a/plugins/example/src/database/events.ts b/plugins/example/src/database/events.ts new file mode 100644 index 0000000000..d93f6d42fe --- /dev/null +++ b/plugins/example/src/database/events.ts @@ -0,0 +1,27 @@ +import { createContentModel } from "@vitnode/core/content/server"; + +import { eventContentType } from "@/content/event"; + +import { example_categories } from "./categories"; +import { example_tags } from "./tags"; + +export const eventContent = createContentModel(eventContentType, { + // Each foreign key takes the type of the key it points at - `uuid` for the + // tags, `integer` for the category - and a reference whose column disagrees + // with the target's `idStrategy` is refused by name when it resolves. + references: { + category: () => example_categories.id, + primaryTag: () => example_tags.id, + tags: () => example_tags.id, + }, +}); + +export const example_events = eventContent.table; +export const example_events_translations = eventContent.translationTable; +export const example_events_tags = eventContent.advancedTables.junctions.tags; +export const example_events_related_events = + eventContent.advancedTables.junctions.relatedEvents; +export const example_events_gallery = + eventContent.advancedTables.junctions.gallery; +export const example_events_sessions = + eventContent.advancedTables.repeatables.sessions; diff --git a/plugins/example/src/database/tags.ts b/plugins/example/src/database/tags.ts new file mode 100644 index 0000000000..b1cc5711ee --- /dev/null +++ b/plugins/example/src/database/tags.ts @@ -0,0 +1,18 @@ +import { createContentModel } from "@vitnode/core/content/server"; + +import { tagContentType } from "@/content/tag"; + +import { example_categories } from "./categories"; + +export const tagContent = createContentModel(tagContentType, { + // `relatedTags` is absent on purpose: a self-relation is resolved from the + // table being built, and its junction's two columns are `uuid` like the key. + references: { category: () => example_categories.id }, +}); + +// Drizzle Kit discovers each table from its export when it globs the built +// `dist/src/database/*.js`. +export const example_tags = tagContent.table; +export const example_tags_translations = tagContent.translationTable; +export const example_tags_related_tags = + tagContent.advancedTables.junctions.relatedTags; diff --git a/plugins/example/src/locales/en.json b/plugins/example/src/locales/en.json index 9b7a215ce1..10021dcab7 100644 --- a/plugins/example/src/locales/en.json +++ b/plugins/example/src/locales/en.json @@ -65,6 +65,35 @@ "name": "Name", "createdAt": "Created" } + }, + "tag": { + "label": "{count, plural, one {Tag} other {Tags}}", + "title": "Tags", + "desc": "Keyed by uuid - ids that give nothing away.", + "fields": { + "name": "Name", + "slug": "Slug", + "category": "Category", + "relatedTags": "Related tags", + "updatedAt": "Updated" + } + }, + "event": { + "label": "{count, plural, one {Event} other {Events}}", + "title": "Events", + "desc": "Keyed by bigint - ids too large for a JavaScript number arrive as exact strings.", + "fields": { + "title": "Title", + "slug": "Slug", + "body": "Description", + "primaryTag": "Main tag", + "category": "Category", + "tags": "Tags", + "relatedEvents": "Related events", + "sessions": "Sessions", + "gallery": "Gallery", + "updatedAt": "Updated" + } } }, "guide": { @@ -113,6 +142,22 @@ "@vitnode/example:category:can_create": "Create categories", "@vitnode/example:category:can_edit": "Edit categories", "@vitnode/example:category:can_delete": "Delete categories", + "@vitnode/example:tag": "Tags", + "@vitnode/example:tag:can_view": "View tags", + "@vitnode/example:tag:can_create": "Create tags", + "@vitnode/example:tag:can_edit": "Edit tags", + "@vitnode/example:tag:can_delete": "Delete tags", + "@vitnode/example:tag:can_publish": "Publish and unpublish tags", + "@vitnode/example:tag:can_restore": "Restore an earlier version of a tag", + "@vitnode/example:tag:can_hide": "Hide and unhide tags", + "@vitnode/example:event": "Events", + "@vitnode/example:event:can_view": "View events", + "@vitnode/example:event:can_create": "Create events", + "@vitnode/example:event:can_edit": "Edit events", + "@vitnode/example:event:can_delete": "Delete events", + "@vitnode/example:event:can_publish": "Publish and unpublish events", + "@vitnode/example:event:can_restore": "Restore an earlier version of an event", + "@vitnode/example:event:can_hide": "Hide and unhide events", "ai_actions": { "@vitnode/example": { "article_excerpt": { From b6c69ebcde0f806134bc55c8836551737a5b4c15 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 09:54:24 +0000 Subject: [PATCH 07/17] =?UTF-8?q?fix(example):=20=F0=9F=90=9B=20serve=20th?= =?UTF-8?q?e=20bigint=20event=20type's=20delivery=20URLs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit example.event publishes /events/:slug, and the boot guard refuses to start the app when no page route serves a content type's delivery path. Adds the event page: the rich text body through EditorContent and its sessions. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VpG3D33ZqXEYjN2N6eNcMK --- plugins/example/src/locales/en.json | 3 + plugins/example/src/pages/event-page.tsx | 104 +++++++++++++++++++++++ plugins/example/src/routes.ts | 5 ++ 3 files changed, 112 insertions(+) create mode 100644 plugins/example/src/pages/event-page.tsx diff --git a/plugins/example/src/locales/en.json b/plugins/example/src/locales/en.json index 10021dcab7..0da3175550 100644 --- a/plugins/example/src/locales/en.json +++ b/plugins/example/src/locales/en.json @@ -128,6 +128,9 @@ "articles": { "published": "Published", "faq": "Frequently asked questions" + }, + "events": { + "sessions": "Sessions" } }, "@vitnode/example:article": "Articles", diff --git a/plugins/example/src/pages/event-page.tsx b/plugins/example/src/pages/event-page.tsx new file mode 100644 index 0000000000..3674bfc93a --- /dev/null +++ b/plugins/example/src/pages/event-page.tsx @@ -0,0 +1,104 @@ +import { EditorContent } from "@vitnode/core/components/ui/editor-content"; +import { + definePluginRoute, + type PluginRoutePageProps, +} from "@vitnode/core/routing"; +import { contentPageItem } from "@vitnode/core/tanstack/content"; +import { fetcher } from "@vitnode/core/tanstack/fetcher"; +import { useTranslations } from "use-intl"; +import { z } from "zod"; + +import { CONFIG_PLUGIN } from "@/const"; + +import { ContentArticle } from "./content-article"; + +// `id` is a decimal string: `example.event` uses the `bigint` id strategy, and +// an identifier above 2^53 would not survive a round trip through a number. +const zodEvent = z.object({ + body: z.string(), + id: z.string(), + publishedAt: z.string().nullable(), + sessions: z.array( + z.object({ startsAt: z.string().nullable(), title: z.string() }), + ), + title: z.string(), +}); + +const loadEvent = async ({ + locale, + slug, +}: { + locale: string; + slug: string; +}) => { + const detail = await fetcher({ + plugin: CONFIG_PLUGIN.pluginId, + method: "get", + module: "content/events", + path: "/{slug}", + args: { + params: { slug: encodeURIComponent(slug) }, + query: { locale }, + }, + }); + + return { + item: contentPageItem( + detail.status === 200 ? zodEvent.parse(await detail.json()) : null, + ), + locale, + }; +}; + +type EventPageData = Awaited>; + +export const route = definePluginRoute({ + load: async ({ context, params }) => + await loadEvent({ locale: context.locale, slug: params.slug }), + + head: ({ loaderData }) => ({ title: loaderData?.item.title }), +}); + +const EventPage = ({ + loaderData: { item, locale }, +}: PluginRoutePageProps) => { + const t = useTranslations("@vitnode/example.events"); + + return ( + +
+ +
+ + {item.sessions.length > 0 ? ( +
+

+ {t("sessions")} +

+
    + {item.sessions.map(session => ( +
  • + {session.title} +
  • + ))} +
+
+ ) : null} +
+ ); +}; + +export default EventPage; diff --git a/plugins/example/src/routes.ts b/plugins/example/src/routes.ts index 26cba4a8af..c16b37fd64 100644 --- a/plugins/example/src/routes.ts +++ b/plugins/example/src/routes.ts @@ -60,6 +60,11 @@ export const routes = definePluginRoutes([ messages: ["@vitnode/example.articles"], }), + page("/events/:slug", { + component: lazy(() => import("./pages/event-page")), + messages: ["@vitnode/example.articles", "@vitnode/example.events"], + }), + page("/localized-articles/:slug", { component: lazy(() => import("./pages/localized-article-page")), messages: ["@vitnode/example.articles"], From 0739e9eb89e8614d9efd7c340cb6ed20477a25b1 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 10:16:46 +0000 Subject: [PATCH 08/17] =?UTF-8?q?fix(content):=20=F0=9F=90=9B=20read=20uui?= =?UTF-8?q?d=20and=20bigint=20ids=20in=20the=20AdminCP=20client?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The AdminCP list, item, mutation and option responses were still parsed with numeric id schemas, so a uuid or bigint content type's list failed to load and its to-many pickers showed raw keys instead of labels. Adds contentAnyIdSchema for code that reads rows of any content type. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VpG3D33ZqXEYjN2N6eNcMK --- packages/vitnode/src/content/ids.ts | 12 ++++++ packages/vitnode/src/content/index.ts | 1 + .../views/content/actions/editorial-api.ts | 4 +- .../admin/views/content/form/item-query.ts | 3 +- .../views/content/form/mutations-api.test.ts | 22 +++++++++++ .../admin/views/content/form/mutations-api.ts | 7 +++- .../views/content/table/list-query.test.ts | 38 +++++++++++++++++++ .../admin/views/content/table/list-query.ts | 3 +- 8 files changed, 85 insertions(+), 5 deletions(-) diff --git a/packages/vitnode/src/content/ids.ts b/packages/vitnode/src/content/ids.ts index 21345a3c86..d5f73aced9 100644 --- a/packages/vitnode/src/content/ids.ts +++ b/packages/vitnode/src/content/ids.ts @@ -2,6 +2,7 @@ import { z } from "zod"; import type { CONTENT_ID_STRATEGIES } from "./const"; +import { CONTENT_ID_KEY_LENGTH } from "./const"; import { ContentEngineError } from "./errors"; /** How a content type's primary key is generated. Defaults to `serial`. */ @@ -152,6 +153,17 @@ export const contentIdSchema = ( .refine(withinBigint, "Out of range for a bigint identifier.") : z.string().regex(UUID_PATTERN, "Expected a lowercase UUID."); +/** + * One record identifier under any strategy, for code that reads rows of content + * types it does not know statically - the generic AdminCP. A positive integer + * (`serial`) or a canonical key string (`uuid`, `bigint`); never a number for a + * string strategy, so a bigint above 2^53 is never rounded on the way in. + */ +export const contentAnyIdSchema: z.ZodType = z.union([ + z.number().int().positive().max(CONTENT_SERIAL_MAX), + z.string().min(1).max(CONTENT_ID_KEY_LENGTH), +]); + /** * The Zod schema of one identifier read from a path segment or a query string, * where every value arrives as text. A `serial` is coerced to a number. diff --git a/packages/vitnode/src/content/index.ts b/packages/vitnode/src/content/index.ts index ee6dede4c4..9490cd2135 100644 --- a/packages/vitnode/src/content/index.ts +++ b/packages/vitnode/src/content/index.ts @@ -271,6 +271,7 @@ export { compareContentIds, CONTENT_BIGINT_MAX, CONTENT_SERIAL_MAX, + contentAnyIdSchema, contentIdFromKey, contentIdKey, contentIdParamSchema, diff --git a/packages/vitnode/src/views/admin/views/content/actions/editorial-api.ts b/packages/vitnode/src/views/admin/views/content/actions/editorial-api.ts index daf1d74a20..0e2d19f7bd 100644 --- a/packages/vitnode/src/views/admin/views/content/actions/editorial-api.ts +++ b/packages/vitnode/src/views/admin/views/content/actions/editorial-api.ts @@ -10,6 +10,8 @@ import type { ContentScheduleAction, } from "@/content/schedules"; +import { contentAnyIdSchema } from "@/content/ids"; + import type { ContentMutationResult } from "../content-mutation"; import type { ContentApiTarget } from "../content-request"; @@ -30,7 +32,7 @@ const zodRevisionList = z.object({ const zodRevision = z.object({ id: z.number() }).loose(); -const zodRow = z.object({ id: z.number() }).loose(); +const zodRow = z.object({ id: contentAnyIdSchema }).loose(); const zodRestoreResult = z.object({ changed: z.boolean(), row: zodRow }); diff --git a/packages/vitnode/src/views/admin/views/content/form/item-query.ts b/packages/vitnode/src/views/admin/views/content/form/item-query.ts index 83e06da926..2a050c45e3 100644 --- a/packages/vitnode/src/views/admin/views/content/form/item-query.ts +++ b/packages/vitnode/src/views/admin/views/content/form/item-query.ts @@ -4,6 +4,7 @@ import { z } from "zod"; import type { ContentId } from "@/content/ids"; import type { AnyContentTypeDefinition } from "@/content/types"; +import { contentAnyIdSchema } from "@/content/ids"; import { resolveLangValue } from "@/lib/helpers/multi-lang"; import { RECORD_STALE_TIME } from "@/lib/query-freshness"; @@ -18,7 +19,7 @@ import { contentApiFetch, readContentApiJson } from "../content-request"; export const zodContentItem = z .object({ - id: z.number(), + id: contentAnyIdSchema, labels: z.record(z.string(), z.string().nullable()), }) .loose(); diff --git a/packages/vitnode/src/views/admin/views/content/form/mutations-api.test.ts b/packages/vitnode/src/views/admin/views/content/form/mutations-api.test.ts index c709c210e6..7bea8324f9 100644 --- a/packages/vitnode/src/views/admin/views/content/form/mutations-api.test.ts +++ b/packages/vitnode/src/views/admin/views/content/form/mutations-api.test.ts @@ -321,6 +321,28 @@ describe("picker options", () => { ]); }); + it("labels the options of a uuid or a bigint target by their keys", async () => { + answers(200, { + items: [ + { label: "Launch week", value: "047d2c6c-1d86-47e2-bcd3-2df9f5359541" }, + { label: "Big launch", value: "9007199254740994" }, + ], + }); + + await expect( + loadContentOptionsInBrowser(TARGET, "tags", "", [ + "047d2c6c-1d86-47e2-bcd3-2df9f5359541", + "9007199254740994", + ]), + ).resolves.toEqual([ + { label: "Launch week", value: "047d2c6c-1d86-47e2-bcd3-2df9f5359541" }, + { label: "Big launch", value: "9007199254740994" }, + ]); + expect(sent().query).toEqual({ + ids: "047d2c6c-1d86-47e2-bcd3-2df9f5359541,9007199254740994", + }); + }); + it("asks for identifiers instead of a search when given them", async () => { answers(200, { items: [] }); diff --git a/packages/vitnode/src/views/admin/views/content/form/mutations-api.ts b/packages/vitnode/src/views/admin/views/content/form/mutations-api.ts index ecaea813fa..329cd479b6 100644 --- a/packages/vitnode/src/views/admin/views/content/form/mutations-api.ts +++ b/packages/vitnode/src/views/admin/views/content/form/mutations-api.ts @@ -5,6 +5,7 @@ import type { ContentPublicationAction } from "@/content/publication"; import type { ContentVisibilityAction } from "@/content/visibility"; import { CONTENT_OPTIONS_LIMIT } from "@/content/const"; +import { contentAnyIdSchema } from "@/content/ids"; import type { ContentMutationResult, @@ -23,7 +24,7 @@ import { } from "../lib/api-result"; /** Anything the generated routes return: an identifier plus the row's fields. */ -const zodRow = z.object({ id: z.number() }).loose(); +const zodRow = z.object({ id: contentAnyIdSchema }).loose(); const zodPublicationResult = z.object({ changed: z.boolean(), row: zodRow }); @@ -38,7 +39,9 @@ const zodOptions = z.object({ color: z.string().optional(), label: z.string(), nameCode: z.string().optional(), - value: z.number(), + // A number for a `serial` target or a person, the canonical key string + // for a `uuid` or `bigint` one. + value: contentAnyIdSchema, }), ), }); diff --git a/packages/vitnode/src/views/admin/views/content/table/list-query.test.ts b/packages/vitnode/src/views/admin/views/content/table/list-query.test.ts index c7510f35cf..8573a5764a 100644 --- a/packages/vitnode/src/views/admin/views/content/table/list-query.test.ts +++ b/packages/vitnode/src/views/admin/views/content/table/list-query.test.ts @@ -226,3 +226,41 @@ describe("one page, as the table model", () => { expect(zodContentListPage.safeParse({ edges: [] }).success).toBe(false); }); }); + +describe("the list page", () => { + const pageInfo = { + count: 2, + currentPage: 1, + endCursor: null, + hasNextPage: false, + hasPreviousPage: false, + pageSize: 25, + startCursor: null, + totalCount: 2, + totalPages: 1, + }; + + it("reads rows of a uuid or a bigint content type with their ids intact", () => { + const page = zodContentListPage.parse({ + edges: [ + { id: "047d2c6c-1d86-47e2-bcd3-2df9f5359541", labels: {} }, + { id: "9007199254740994", labels: {} }, + ], + pageInfo, + }); + + expect(page.edges.map(row => row.id)).toEqual([ + "047d2c6c-1d86-47e2-bcd3-2df9f5359541", + "9007199254740994", + ]); + }); + + it("still reads a serial content type's rows as numbers", () => { + const page = zodContentListPage.parse({ + edges: [{ id: 7, labels: {} }], + pageInfo: { ...pageInfo, count: 1, totalCount: 1 }, + }); + + expect(page.edges[0]?.id).toBe(7); + }); +}); diff --git a/packages/vitnode/src/views/admin/views/content/table/list-query.ts b/packages/vitnode/src/views/admin/views/content/table/list-query.ts index b19ae0fcf7..5990dbd5d6 100644 --- a/packages/vitnode/src/views/admin/views/content/table/list-query.ts +++ b/packages/vitnode/src/views/admin/views/content/table/list-query.ts @@ -3,6 +3,7 @@ import { z } from "zod"; import type { AdminTablePage } from "@/views/admin/table/params"; +import { contentAnyIdSchema } from "@/content/ids"; import { RECORD_STALE_TIME } from "@/lib/query-freshness"; import type { ContentApiRequest, ContentApiTarget } from "../content-request"; @@ -28,7 +29,7 @@ const zodPageInfo = z.object({ const zodListRow = z .object({ - id: z.number(), + id: contentAnyIdSchema, labels: z.record(z.string(), z.string().nullable()), translation: z .object({ From 0239721cdfc751c89a3d1fc34bb388d4b7d8faec Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 09:29:18 +0000 Subject: [PATCH 09/17] =?UTF-8?q?feat(content):=20=E2=9C=A8=20key=20live?= =?UTF-8?q?=20editing=20and=20AI=20translation=20sources=20by=20id=20strat?= =?UTF-8?q?egy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Canary's live editing (field locks, shared drafts, collaborative documents) and AI translation sources stored a content record's itemId as an integer. They now store contentIdKey(id) in varchar(64), like the other shared content tables, and read it back under the content type's own strategy. Socket rooms accept any strategy's id and the server re-parses it before it reaches Postgres; the live routes parse ids like every other generated route; the AdminCP live editor and translation freshness carry ContentId. Also drops a table test that asserted a column's SQL type. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VpG3D33ZqXEYjN2N6eNcMK --- .../ai/routes/translation-sources.route.ts | 46 +++++++++++++++---- packages/vitnode/src/content/live/protocol.ts | 15 ++++-- packages/vitnode/src/content/revisions.ts | 2 +- .../src/content/server/column-builders.ts | 2 +- .../vitnode/src/content/server/live-routes.ts | 24 ++++++---- .../vitnode/src/content/server/live-store.ts | 26 ++++++++--- .../vitnode/src/content/server/live/auth.ts | 10 ++-- .../src/content/server/live/document-store.ts | 7 +-- .../src/content/server/live/messages.ts | 6 ++- .../vitnode/src/content/server/live/server.ts | 11 ++++- .../vitnode/src/content/server/table.test.ts | 15 ------ packages/vitnode/src/content/server/types.ts | 4 +- packages/vitnode/src/database/ai.ts | 4 +- .../vitnode/src/database/content-documents.ts | 4 +- .../vitnode/src/database/content-drafts.ts | 11 +++-- .../views/content/actions/content-form.tsx | 6 +-- .../admin/views/content/form/context.tsx | 3 +- .../content/form/translation-freshness.ts | 5 +- .../admin/views/content/form/transport.tsx | 14 +++--- .../src/views/admin/views/content/live/api.ts | 11 +++-- .../views/admin/views/content/live/context.ts | 3 +- 21 files changed, 149 insertions(+), 80 deletions(-) diff --git a/packages/vitnode/src/api/modules/admin/ai/routes/translation-sources.route.ts b/packages/vitnode/src/api/modules/admin/ai/routes/translation-sources.route.ts index bc93cb5feb..ef18abb8d7 100644 --- a/packages/vitnode/src/api/modules/admin/ai/routes/translation-sources.route.ts +++ b/packages/vitnode/src/api/modules/admin/ai/routes/translation-sources.route.ts @@ -7,9 +7,23 @@ import { z } from "zod"; import { checkStaffPermission } from "@/api/lib/check-staff-permission"; import { buildRoute } from "@/api/lib/route"; import { CONFIG_PLUGIN } from "@/config"; +import { + contentAnyIdSchema, + contentIdKey, + parseContentId, +} from "@/content/ids"; import { core_ai_translation_sources } from "@/database/ai"; -const assertCanEditContent = async (c: Context, contentTypeId: string) => { +/** + * Checks `can_edit` on the content type and returns the item's storage key: the + * id is read under the content type's own strategy, so a malformed one is a 400 + * rather than a database error, and a serial `7` and `"7"` are the same record. + */ +const assertCanEditContentItem = async ( + c: Context, + contentTypeId: string, + rawItemId: unknown, +): Promise => { const registered = c .get("core") .contentTypes.find(entry => entry.definition.id === contentTypeId); @@ -23,6 +37,13 @@ const assertCanEditContent = async (c: Context, contentTypeId: string) => { type: "admin", }); if (!allowed) throw new HTTPException(403); + + const itemId = parseContentId(registered.definition.idStrategy, rawItemId); + if (itemId === null) { + throw new HTTPException(400, { message: "Invalid identifier." }); + } + + return contentIdKey(itemId); }; const zodTranslationSourceRecord = z.object({ @@ -45,7 +66,7 @@ export const getTranslationSourcesAdminRoute = buildRoute({ request: { query: z.object({ contentTypeId: z.string().min(1).max(255), - itemId: z.coerce.number().int().positive(), + itemId: contentAnyIdSchema, }), }, responses: { @@ -60,8 +81,13 @@ export const getTranslationSourcesAdminRoute = buildRoute({ }, }, handler: async c => { - const { contentTypeId, itemId } = c.req.valid("query"); - await assertCanEditContent(c, contentTypeId); + const query = c.req.valid("query"); + const { contentTypeId } = query; + const itemId = await assertCanEditContentItem( + c, + contentTypeId, + query.itemId, + ); const records = await c .get("db") @@ -111,7 +137,7 @@ export const saveTranslationSourcesAdminRoute = buildRoute({ ) .min(1) .max(100), - itemId: z.number().int().positive(), + itemId: contentAnyIdSchema, locale: z.string().min(2).max(32), sourceLocale: z.string().min(2).max(32), }), @@ -129,9 +155,13 @@ export const saveTranslationSourcesAdminRoute = buildRoute({ }, }, handler: async c => { - const { contentTypeId, fields, itemId, locale, sourceLocale } = - c.req.valid("json"); - await assertCanEditContent(c, contentTypeId); + const body = c.req.valid("json"); + const { contentTypeId, fields, locale, sourceLocale } = body; + const itemId = await assertCanEditContentItem( + c, + contentTypeId, + body.itemId, + ); const updatedById = c.get("admin")?.user.id ?? null; for (const entry of fields) { diff --git a/packages/vitnode/src/content/live/protocol.ts b/packages/vitnode/src/content/live/protocol.ts index f928534268..d2873cb85a 100644 --- a/packages/vitnode/src/content/live/protocol.ts +++ b/packages/vitnode/src/content/live/protocol.ts @@ -1,8 +1,12 @@ +import type { ContentId } from "@/content/ids"; + +import { contentIdKey } from "@/content/ids"; import { createWebSocketChannel } from "@/ws/types"; export interface ContentLiveRoomRef { contentTypeId: string; - itemId: number; + /** A number for a `serial` content type, the key string for `uuid` / `bigint`. */ + itemId: ContentId; } export interface ContentLiveDocRef extends ContentLiveRoomRef { @@ -138,7 +142,8 @@ export const contentLiveChannel = createWebSocketChannel< export const contentLiveRoom = ({ contentTypeId, itemId, -}: ContentLiveRoomRef): string => `content:${contentTypeId}:${itemId}`; +}: ContentLiveRoomRef): string => + `content:${contentTypeId}:${contentIdKey(itemId)}`; export const contentLiveDocRoom = ({ contentTypeId, @@ -146,12 +151,14 @@ export const contentLiveDocRoom = ({ itemId, locale, }: ContentLiveDocRef): string => - `content-doc:${contentTypeId}:${itemId}:${field}:${locale ?? "_"}`; + `content-doc:${contentTypeId}:${contentIdKey(itemId)}:${field}:${locale ?? "_"}`; export const sameContentLiveRoom = ( a: ContentLiveRoomRef, b: ContentLiveRoomRef, -): boolean => a.contentTypeId === b.contentTypeId && a.itemId === b.itemId; +): boolean => + a.contentTypeId === b.contentTypeId && + contentIdKey(a.itemId) === contentIdKey(b.itemId); export const sameContentLiveDoc = ( a: ContentLiveDocRef, diff --git a/packages/vitnode/src/content/revisions.ts b/packages/vitnode/src/content/revisions.ts index 301d0bab04..8ee5a5a430 100644 --- a/packages/vitnode/src/content/revisions.ts +++ b/packages/vitnode/src/content/revisions.ts @@ -3,8 +3,8 @@ import type { CONTENT_REVISION_OPERATIONS, CONTENT_TRANSLATION_REVISION_OPERATIONS, } from "./const"; -import type { RichTextDocument } from "./rich-text/document"; import type { ContentId } from "./ids"; +import type { RichTextDocument } from "./rich-text/document"; export type ContentRevisionOperation = (typeof CONTENT_REVISION_OPERATIONS)[number]; diff --git a/packages/vitnode/src/content/server/column-builders.ts b/packages/vitnode/src/content/server/column-builders.ts index 421104e2a4..29c03bdf35 100644 --- a/packages/vitnode/src/content/server/column-builders.ts +++ b/packages/vitnode/src/content/server/column-builders.ts @@ -14,8 +14,8 @@ import { } from "drizzle-orm/pg-core"; import type { AnyBlockInstance } from "../../blocks/types"; -import type { RichTextDocument } from "../rich-text/document"; import type { ContentIdStrategy } from "../ids"; +import type { RichTextDocument } from "../rich-text/document"; import type { ContentFieldDescriptor } from "../types"; import { diff --git a/packages/vitnode/src/content/server/live-routes.ts b/packages/vitnode/src/content/server/live-routes.ts index 2cb1d8c47a..5913573691 100644 --- a/packages/vitnode/src/content/server/live-routes.ts +++ b/packages/vitnode/src/content/server/live-routes.ts @@ -3,6 +3,7 @@ import type { Context } from "hono"; import { z } from "@hono/zod-openapi"; import { HTTPException } from "hono/http-exception"; +import type { ContentIdOf } from "../ids"; import type { ContentLiveRoomRef } from "../live/protocol"; import type { AnyContentTypeDefinition, ContentFieldMap } from "../types"; import type { ContentModel } from "./model"; @@ -38,7 +39,12 @@ import { } from "./live-store"; import { broadcastContentLive } from "./live/broadcast"; import { resetContentLiveRecord } from "./live/hooks"; -import { identifier, jsonBody, jsonResponse, readJson } from "./route-helpers"; +import { + contentIdentifier, + jsonBody, + jsonResponse, + readJson, +} from "./route-helpers"; export const buildContentLiveRoutes = < TDefinition extends AnyContentTypeDefinition, @@ -60,19 +66,19 @@ export const buildContentLiveRoutes = < }; const localized = definition.localization.enabled; - const params = z.object({ id: z.coerce.number() }); + const params = definition.schemas.params; const notFound = { description: `${name} not found` }; const db = (c: Context): ContentDatabase => c.get("db"); - const roomOf = (id: number): ContentLiveRoomRef => ({ + const roomOf = (id: ContentIdOf): ContentLiveRoomRef => ({ contentTypeId: definition.id, itemId: id, }); const findRecord = async ( c: Context, - id: number, + id: ContentIdOf, ): Promise> => { const row = await model.service(c).findRowById(id); if (!row) { @@ -142,7 +148,7 @@ export const buildContentLiveRoutes = < }, }, handler: async c => { - const id = identifier(c); + const id = contentIdentifier(c, definition); await findRecord(c, id); return c.json( @@ -171,7 +177,7 @@ export const buildContentLiveRoutes = < }, }, handler: async c => { - const id = identifier(c); + const id = contentIdentifier(c, definition); const body = await readJson(c, zodContentFieldLockRequest); const user = editor(c); const locale = await scopeOf(c, body.field, body.locale); @@ -222,7 +228,7 @@ export const buildContentLiveRoutes = < }, }, handler: async c => { - const id = identifier(c); + const id = contentIdentifier(c, definition); await findRecord(c, id); return c.json(await readContentDrafts(db(c), roomOf(id)), 200); @@ -251,7 +257,7 @@ export const buildContentLiveRoutes = < }, }, handler: async c => { - const id = identifier(c); + const id = contentIdentifier(c, definition); const body = await readJson(c, zodContentDraftWrite); const user = editor(c); const names = Object.keys(body.values); @@ -350,7 +356,7 @@ export const buildContentLiveRoutes = < }, }, handler: async c => { - const id = identifier(c); + const id = contentIdentifier(c, definition); await findRecord(c, id); await resetContentLiveRecord(c, roomOf(id), "discarded"); diff --git a/packages/vitnode/src/content/server/live-store.ts b/packages/vitnode/src/content/server/live-store.ts index 5f97f1b362..9c9b9464d7 100644 --- a/packages/vitnode/src/content/server/live-store.ts +++ b/packages/vitnode/src/content/server/live-store.ts @@ -11,6 +11,7 @@ import { core_content_field_locks, } from "../../database/content-drafts"; import { core_users } from "../../database/users"; +import { contentIdFromKey, contentIdKey } from "../ids"; import { CONTENT_FIELD_LOCK_LEASE_MS, CONTENT_LIVE_SHARED_LOCALE, @@ -18,7 +19,7 @@ import { } from "../live/protocol"; import { broadcastContentLive } from "./live/broadcast"; import { onContentLiveReset, onContentLiveUserLeft } from "./live/hooks"; -import { findContentModel } from "./model"; +import { contentDefinitionOf, findContentModel } from "./model"; const DAY_MS = 24 * 60 * 60 * 1000; @@ -34,7 +35,11 @@ const localeOf = (language: string): null | string => const inRoom = ( table: typeof core_content_drafts | typeof core_content_field_locks, { contentTypeId, itemId }: ContentLiveRoomRef, -) => and(eq(table.contentTypeId, contentTypeId), eq(table.itemId, itemId)); +) => + and( + eq(table.contentTypeId, contentTypeId), + eq(table.itemId, contentIdKey(itemId)), + ); export interface ContentFieldLockKey extends ContentLiveRoomRef { field: string; @@ -114,7 +119,7 @@ export const acquireContentFieldLock = async ( contentTypeId: key.contentTypeId, expiresAt, field: key.field, - itemId: key.itemId, + itemId: contentIdKey(key.itemId), language: contentLiveLanguage(key.locale), userId, }) @@ -258,7 +263,7 @@ export const writeContentDraft = async ( .values({ baseVersion, contentTypeId: room.contentTypeId, - itemId: room.itemId, + itemId: contentIdKey(room.itemId), language: contentLiveLanguage(locale), updatedAt: now, updatedBy: userId, @@ -368,7 +373,16 @@ export const cleanupStaleContentDrafts = async ( } const locale = localeOf(draft.language); - const record = await entry.model.service(c).findRowById(draft.itemId); + // Stored as its key; read back under the content type's own strategy. + const itemId = contentIdFromKey( + contentDefinitionOf(entry.model).idStrategy, + draft.itemId, + ); + if (itemId === null) { + doomed.push(draft.id); + continue; + } + const record = await entry.model.service(c).findRowById(itemId); if (!record) { doomed.push(draft.id); continue; @@ -384,7 +398,7 @@ export const cleanupStaleContentDrafts = async ( const translation = await entry.model .translationService?.(c) - .findByLocale(draft.itemId, locale); + .findByLocale(itemId, locale); if ( translation ? translation.version > draft.baseVersion diff --git a/packages/vitnode/src/content/server/live/auth.ts b/packages/vitnode/src/content/server/live/auth.ts index 77f2b9c5c6..b487d64706 100644 --- a/packages/vitnode/src/content/server/live/auth.ts +++ b/packages/vitnode/src/content/server/live/auth.ts @@ -9,6 +9,7 @@ import type { RegisteredContentType } from "@/content/registry"; import { checkStaffPermissionOfUser } from "@/api/lib/check-staff-permission"; import { SessionAdminModel } from "@/api/models/session-admin"; import { CONTENT_PERMISSIONS } from "@/content/const"; +import { parseContentId } from "@/content/ids"; import { contentLiveRoom } from "@/content/live/protocol"; import { findContentLanguage } from "@/content/server/language-resolver"; import { findContentModel } from "@/content/server/model"; @@ -68,13 +69,16 @@ export const authorizeContentLive = async ( }); if (!allowed) return { code: "FORBIDDEN", ok: false }; + // The socket accepts any strategy's id; this content type's own strategy + // decides whether it is one, so a malformed id never reaches Postgres. + const itemId = parseContentId(entry.definition.idStrategy, room.itemId); + if (itemId === null) return { code: "NOT_FOUND", ok: false }; + const model = findContentModel( c.get("core").contentModels, room.contentTypeId, ); - const row = model - ? await model.model.service(c).findById(room.itemId) - : undefined; + const row = model ? await model.model.service(c).findById(itemId) : undefined; if (!row) return { code: "NOT_FOUND", ok: false }; const { avatarColor, id, name, nameCode } = session.user; diff --git a/packages/vitnode/src/content/server/live/document-store.ts b/packages/vitnode/src/content/server/live/document-store.ts index 9baf436a3d..74e980990f 100644 --- a/packages/vitnode/src/content/server/live/document-store.ts +++ b/packages/vitnode/src/content/server/live/document-store.ts @@ -4,6 +4,7 @@ import { and, eq, lt, sql } from "drizzle-orm"; import type { ContentLiveDocRef } from "@/content/live/protocol"; +import { contentIdKey } from "@/content/ids"; import { CONTENT_LIVE_SHARED_LOCALE, CONTENT_LIVE_STALE_DAYS, @@ -22,7 +23,7 @@ const languageOf = (doc: ContentLiveDocRef): string => const whereDoc = (doc: ContentLiveDocRef) => and( eq(table.contentTypeId, doc.contentTypeId), - eq(table.itemId, doc.itemId), + eq(table.itemId, contentIdKey(doc.itemId)), eq(table.field, doc.field), eq(table.language, languageOf(doc)), ); @@ -32,7 +33,7 @@ const isUnseeded = sql`octet_length(${table.state}) = 0`; const keyOf = (doc: ContentLiveDocRef) => ({ contentTypeId: doc.contentTypeId, field: doc.field, - itemId: doc.itemId, + itemId: contentIdKey(doc.itemId), language: languageOf(doc), }); @@ -74,7 +75,7 @@ export const createContentDocumentStore = ( .where( and( eq(table.contentTypeId, room.contentTypeId), - eq(table.itemId, room.itemId), + eq(table.itemId, contentIdKey(room.itemId)), ), ); }, diff --git a/packages/vitnode/src/content/server/live/messages.ts b/packages/vitnode/src/content/server/live/messages.ts index c0e5fc75d8..50675c154c 100644 --- a/packages/vitnode/src/content/server/live/messages.ts +++ b/packages/vitnode/src/content/server/live/messages.ts @@ -2,6 +2,8 @@ import { z } from "zod"; import type { ContentLiveClientMessage } from "@/content/live/protocol"; +import { contentAnyIdSchema } from "@/content/ids"; + export const CONTENT_LIVE_MAX_UPDATE_BYTES = 2 * 1024 * 1024; const MAX_BASE64_LENGTH = Math.ceil(CONTENT_LIVE_MAX_UPDATE_BYTES / 3) * 4; @@ -15,13 +17,13 @@ const base64 = z const room = z.strictObject({ contentTypeId: z.string().min(1).max(100), - itemId: z.number().int().positive(), + itemId: contentAnyIdSchema, }); const doc = z.strictObject({ contentTypeId: z.string().min(1).max(100), field: z.string().min(1).max(100), - itemId: z.number().int().positive(), + itemId: contentAnyIdSchema, locale, }); diff --git a/packages/vitnode/src/content/server/live/server.ts b/packages/vitnode/src/content/server/live/server.ts index 03cd05ad3b..4c27ff2b86 100644 --- a/packages/vitnode/src/content/server/live/server.ts +++ b/packages/vitnode/src/content/server/live/server.ts @@ -10,6 +10,7 @@ import type { ContentLiveServerMessage, } from "@/content/live/protocol"; +import { contentAnyIdSchema } from "@/content/ids"; import { CONTENT_LIVE_HEARTBEAT_MS, contentLiveChannel, @@ -58,11 +59,17 @@ const snapshotSchema = z.object({ userId: z.number(), }), ), - room: z.object({ contentTypeId: z.string(), itemId: z.number() }), + room: z.object({ + contentTypeId: z.string(), + itemId: contentAnyIdSchema, + }), }) satisfies z.ZodType; const resetSchema = z.object({ - room: z.object({ contentTypeId: z.string(), itemId: z.number() }), + room: z.object({ + contentTypeId: z.string(), + itemId: contentAnyIdSchema, + }), type: z.literal("reset"), }); diff --git a/packages/vitnode/src/content/server/table.test.ts b/packages/vitnode/src/content/server/table.test.ts index d04d482b40..b4068210b4 100644 --- a/packages/vitnode/src/content/server/table.test.ts +++ b/packages/vitnode/src/content/server/table.test.ts @@ -139,21 +139,6 @@ describe("createContentTable", () => { tableName: "test_pointing", }); - it("builds the foreign key with the type of the key it points at", () => { - const uuidCategories = createContentTable(uuidCategory); - const table = createContentTable( - pointing(() => uuidCategory) as typeof testArticleContentType, - { references: { category: () => uuidCategories.id } }, - ); - - expect( - getTableConfig(table) - .columns.find(item => item.name === "category") - ?.getSQLType(), - ).toBe("uuid"); - expect(() => assertContentReferences(table)).not.toThrow(); - }); - it("names the field when the referenced key has another type", () => { // The descriptor says the target is keyed by `uuid`, while the table // handed to `references` is the serial one: a column built as `uuid` diff --git a/packages/vitnode/src/content/server/types.ts b/packages/vitnode/src/content/server/types.ts index 84e2f5c1dd..5439544ba6 100644 --- a/packages/vitnode/src/content/server/types.ts +++ b/packages/vitnode/src/content/server/types.ts @@ -19,8 +19,8 @@ import type { SetNotNull, } from "drizzle-orm/pg-core"; -import type { RichTextDocument } from "../rich-text/document"; import type { ContentIdStrategy } from "../ids"; +import type { RichTextDocument } from "../rich-text/document"; import type { ContentEditorialField, ContentFieldsOf, @@ -82,7 +82,7 @@ type BaseBuilderFor = TField extends { kind: "relation" } ? PgTextBuilder : TField extends { kind: "richText" } ? $Type - : PgVarcharBuilder; + : PgVarcharBuilder; type ApplyDefault = HasColumnDefault extends true ? SetHasDefault : TBuilder; diff --git a/packages/vitnode/src/database/ai.ts b/packages/vitnode/src/database/ai.ts index e1305c9845..5377781e93 100644 --- a/packages/vitnode/src/database/ai.ts +++ b/packages/vitnode/src/database/ai.ts @@ -3,6 +3,7 @@ import { camelCase, index, primaryKey, uniqueIndex } from "drizzle-orm/pg-core"; import type { AiPricing } from "@/api/lib/ai/pricing"; +import { CONTENT_ID_KEY_LENGTH } from "../content/const"; import { core_roles } from "./roles"; import { core_users } from "./users"; @@ -297,7 +298,8 @@ export const core_ai_translation_sources = camelCase.table.withRLS( t => ({ id: t.serial().primaryKey(), contentTypeId: t.varchar({ length: 255 }).notNull(), - itemId: t.integer().notNull(), + /** `contentIdKey(id)`: the digits of a serial or bigint id, or a uuid. */ + itemId: t.varchar({ length: CONTENT_ID_KEY_LENGTH }).notNull(), locale: t.varchar({ length: 32 }).notNull(), field: t.varchar({ length: 255 }).notNull(), sourceLocale: t.varchar({ length: 32 }).notNull(), diff --git a/packages/vitnode/src/database/content-documents.ts b/packages/vitnode/src/database/content-documents.ts index 504f50503e..76ca84891a 100644 --- a/packages/vitnode/src/database/content-documents.ts +++ b/packages/vitnode/src/database/content-documents.ts @@ -1,5 +1,6 @@ import { camelCase, customType, index, uniqueIndex } from "drizzle-orm/pg-core"; +import { CONTENT_ID_KEY_LENGTH } from "../content/const"; import { core_users } from "./users"; const bytea = customType<{ data: Uint8Array; driverData: Buffer }>({ @@ -19,7 +20,8 @@ export const core_content_documents = camelCase.table.withRLS( t => ({ id: t.serial().primaryKey(), contentTypeId: t.varchar({ length: 100 }).notNull(), - itemId: t.integer().notNull(), + /** `contentIdKey(id)`: the digits of a serial or bigint id, or a uuid. */ + itemId: t.varchar({ length: CONTENT_ID_KEY_LENGTH }).notNull(), field: t.varchar({ length: 100 }).notNull(), language: t.varchar({ length: 35 }).notNull().default(""), state: bytea().notNull(), diff --git a/packages/vitnode/src/database/content-drafts.ts b/packages/vitnode/src/database/content-drafts.ts index 7c997f2aef..07070c8234 100644 --- a/packages/vitnode/src/database/content-drafts.ts +++ b/packages/vitnode/src/database/content-drafts.ts @@ -1,6 +1,9 @@ import { camelCase, index, uniqueIndex } from "drizzle-orm/pg-core"; -import { CONTENT_LOCALE_MAX_LENGTH } from "../content/const"; +import { + CONTENT_ID_KEY_LENGTH, + CONTENT_LOCALE_MAX_LENGTH, +} from "../content/const"; import { core_users } from "./users"; export const core_content_field_locks = camelCase.table.withRLS( @@ -8,7 +11,8 @@ export const core_content_field_locks = camelCase.table.withRLS( t => ({ id: t.serial().primaryKey(), contentTypeId: t.varchar({ length: 100 }).notNull(), - itemId: t.integer().notNull(), + /** `contentIdKey(id)`: the digits of a serial or bigint id, or a uuid. */ + itemId: t.varchar({ length: CONTENT_ID_KEY_LENGTH }).notNull(), field: t.varchar({ length: 100 }).notNull(), language: t .varchar({ length: CONTENT_LOCALE_MAX_LENGTH }) @@ -43,7 +47,8 @@ export const core_content_drafts = camelCase.table.withRLS( t => ({ id: t.serial().primaryKey(), contentTypeId: t.varchar({ length: 100 }).notNull(), - itemId: t.integer().notNull(), + /** `contentIdKey(id)`: the digits of a serial or bigint id, or a uuid. */ + itemId: t.varchar({ length: CONTENT_ID_KEY_LENGTH }).notNull(), language: t .varchar({ length: CONTENT_LOCALE_MAX_LENGTH }) .notNull() diff --git a/packages/vitnode/src/views/admin/views/content/actions/content-form.tsx b/packages/vitnode/src/views/admin/views/content/actions/content-form.tsx index 381da79dfa..96cd0f7460 100644 --- a/packages/vitnode/src/views/admin/views/content/actions/content-form.tsx +++ b/packages/vitnode/src/views/admin/views/content/actions/content-form.tsx @@ -5,8 +5,8 @@ import { useLocale, useTranslations } from "use-intl"; import type { ItemAutoFormComponentProps } from "@/components/form/auto-form"; import type { ContentFormSpec } from "@/content/admin/spec"; import type { ContentFileFieldValue } from "@/content/files"; -import type { ContentVisibilityAction } from "@/content/visibility"; import type { ContentId } from "@/content/ids"; +import type { ContentVisibilityAction } from "@/content/visibility"; import type { ContentFormLayout } from "@/lib/plugin"; import { AutoForm, type AutoFormOnSubmit } from "@/components/form/auto-form"; @@ -336,10 +336,10 @@ const ContentFormFields = ({ }; const savedListenersRef = React.useRef( - new Set<(saved: { itemId: number }) => Promise | void>(), + new Set<(saved: { itemId: ContentId }) => Promise | void>(), ); const onSaved = React.useCallback( - (listener: (saved: { itemId: number }) => Promise | void) => { + (listener: (saved: { itemId: ContentId }) => Promise | void) => { savedListenersRef.current.add(listener); return () => { diff --git a/packages/vitnode/src/views/admin/views/content/form/context.tsx b/packages/vitnode/src/views/admin/views/content/form/context.tsx index 5b9ecf5f8f..4bf154ba21 100644 --- a/packages/vitnode/src/views/admin/views/content/form/context.tsx +++ b/packages/vitnode/src/views/admin/views/content/form/context.tsx @@ -2,6 +2,7 @@ import React from "react"; import type { PageTitleBack } from "@/components/ui/page-title"; import type { ContentFileFieldValue } from "@/content/files"; +import type { ContentId } from "@/content/ids"; import type { ContentVisibilityAction } from "@/content/visibility"; export interface ContentFormHeaderValue { @@ -27,7 +28,7 @@ export interface ContentFormContextValue { markRendered?: (name: string) => void; mode: "create" | "edit"; onSaved?: ( - listener: (saved: { itemId: number }) => Promise | void, + listener: (saved: { itemId: ContentId }) => Promise | void, ) => () => void; publication: { canPublish: boolean; diff --git a/packages/vitnode/src/views/admin/views/content/form/translation-freshness.ts b/packages/vitnode/src/views/admin/views/content/form/translation-freshness.ts index bb7cbfb4dc..532b5454f5 100644 --- a/packages/vitnode/src/views/admin/views/content/form/translation-freshness.ts +++ b/packages/vitnode/src/views/admin/views/content/form/translation-freshness.ts @@ -1,6 +1,7 @@ import { useQuery, useQueryClient } from "@tanstack/react-query"; import React from "react"; +import type { ContentId } from "@/content/ids"; import type { TranslationSourceRecord } from "@/lib/ai/translation-freshness"; import { @@ -18,7 +19,7 @@ interface PendingRecord { sourceFingerprint: string; } -const recordsKey = (contentTypeId: string, itemId: number) => [ +const recordsKey = (contentTypeId: string, itemId: ContentId) => [ "ai", "translation-sources", contentTypeId, @@ -34,7 +35,7 @@ export const useTranslationFreshness = ({ }: { contentTypeId: string; fields: readonly Field[]; - itemId: number | undefined; + itemId: ContentId | undefined; source: string; values: Partial>; }) => { diff --git a/packages/vitnode/src/views/admin/views/content/form/transport.tsx b/packages/vitnode/src/views/admin/views/content/form/transport.tsx index e3902316ed..9c346e2a86 100644 --- a/packages/vitnode/src/views/admin/views/content/form/transport.tsx +++ b/packages/vitnode/src/views/admin/views/content/form/transport.tsx @@ -1,9 +1,9 @@ import React from "react"; +import type { ContentId } from "@/content/ids"; import type { ContentDrafts } from "@/content/live/http"; import type { ContentFieldLock } from "@/content/live/protocol"; import type { ContentVisibilityAction } from "@/content/visibility"; -import type { ContentId } from "@/content/ids"; import type { ContentMutationResult, @@ -33,7 +33,7 @@ export interface ContentFormTransport { discardDraft: ( contentTypeId: string, - itemId: number, + itemId: ContentId, ) => Promise<{ error?: string; status: number }>; edit: ( @@ -53,7 +53,7 @@ export interface ContentFormTransport { listLocks: ( contentTypeId: string, - itemId: number, + itemId: ContentId, ) => Promise<{ error?: string; locks: ContentFieldLock[] }>; listTranslations: ( @@ -69,7 +69,7 @@ export interface ContentFormTransport { ) => Promise; lock: ( contentTypeId: string, - itemId: number, + itemId: ContentId, request: { action: ContentFieldLockAction; field: string; @@ -85,7 +85,7 @@ export interface ContentFormTransport { readDraft: ( contentTypeId: string, - itemId: number, + itemId: ContentId, ) => Promise<{ drafts?: ContentDrafts; error?: string }>; reloadRow: ( @@ -95,7 +95,7 @@ export interface ContentFormTransport { saveDraft: ( contentTypeId: string, - itemId: number, + itemId: ContentId, draft: { locale: null | string; values: Record }, ) => Promise; /** @@ -106,7 +106,7 @@ export interface ContentFormTransport { */ setHidden?: ( contentTypeId: string, - itemId: number, + itemId: ContentId, action: ContentVisibilityAction, expectedVersion?: number, ) => Promise; diff --git a/packages/vitnode/src/views/admin/views/content/live/api.ts b/packages/vitnode/src/views/admin/views/content/live/api.ts index 45e2c55acc..bd2332f563 100644 --- a/packages/vitnode/src/views/admin/views/content/live/api.ts +++ b/packages/vitnode/src/views/admin/views/content/live/api.ts @@ -1,3 +1,4 @@ +import type { ContentId } from "@/content/ids"; import type { ContentDraftRejection, ContentDrafts, @@ -46,7 +47,7 @@ const parseJson = (text: string | undefined): unknown => { export const listContentLocksInBrowser = async ( target: ContentApiTarget, - id: number, + id: ContentId, ): Promise<{ error?: string; locks: ContentFieldLock[] }> => { const result = await send( { method: "get", path: `/${id}/locks`, target }, @@ -60,7 +61,7 @@ export const listContentLocksInBrowser = async ( export const changeContentLockInBrowser = async ( target: ContentApiTarget, - id: number, + id: ContentId, body: { action: ContentFieldLockAction; field: string; @@ -85,7 +86,7 @@ export const changeContentLockInBrowser = async ( export const readContentDraftInBrowser = async ( target: ContentApiTarget, - id: number, + id: ContentId, ): Promise<{ drafts?: ContentDrafts; error?: string }> => { const result = await send( { method: "get", path: `/${id}/draft`, target }, @@ -97,7 +98,7 @@ export const readContentDraftInBrowser = async ( export const discardContentDraftInBrowser = async ( target: ContentApiTarget, - id: number, + id: ContentId, ): Promise<{ error?: string; status: number }> => { const result = await send({ body: {}, @@ -113,7 +114,7 @@ export const discardContentDraftInBrowser = async ( export const saveContentDraftInBrowser = async ( target: ContentApiTarget, - id: number, + id: ContentId, body: { locale: null | string; values: Record }, ): Promise => { const result = await send( diff --git a/packages/vitnode/src/views/admin/views/content/live/context.ts b/packages/vitnode/src/views/admin/views/content/live/context.ts index 2c586a1547..faf19693ad 100644 --- a/packages/vitnode/src/views/admin/views/content/live/context.ts +++ b/packages/vitnode/src/views/admin/views/content/live/context.ts @@ -1,6 +1,7 @@ import React from "react"; import type { ContentFormSpec } from "@/content/admin/spec"; +import type { ContentId } from "@/content/ids"; import type { ContentRichTextRegistry } from "./rich-text"; import type { ContentLiveSession } from "./use-session"; @@ -19,7 +20,7 @@ export interface ContentLiveContextValue { }; coEditing: boolean; discard: () => Promise; - itemId: number; + itemId: ContentId; locale: null | string; reloadDrafts: () => Promise; richText: ContentRichTextRegistry; From 52e43cf6539eb14204f44ec1ea3cd87634340ebd Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 09:34:28 +0000 Subject: [PATCH 10/17] =?UTF-8?q?fix(example):=20=F0=9F=90=9B=20render=20t?= =?UTF-8?q?he=20event=20body=20as=20canary's=20rich=20text=20document?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Canary stores field.richText as a JSON document, so the bigint event page renders it with RichTextContent, and the blog article editor takes the ContentId the form layout now passes. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VpG3D33ZqXEYjN2N6eNcMK --- .../views/admin/article/editor/article-editor.tsx | 3 ++- plugins/example/src/pages/event-page.tsx | 14 +++++++++----- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/plugins/blog/src/views/admin/article/editor/article-editor.tsx b/plugins/blog/src/views/admin/article/editor/article-editor.tsx index 712e0b17ae..5f236acd08 100644 --- a/plugins/blog/src/views/admin/article/editor/article-editor.tsx +++ b/plugins/blog/src/views/admin/article/editor/article-editor.tsx @@ -1,3 +1,4 @@ +import type { ContentId } from "@vitnode/core/content"; import type { RichTextDocument } from "@vitnode/core/content/rich-text"; import { Link } from "@tanstack/react-router"; @@ -84,7 +85,7 @@ export const ArticleEditor = ({ itemId, }: { contentTypeId: string; - itemId: number | undefined; + itemId: ContentId | undefined; }) => { const t = useTranslations("@vitnode/blog.admin.article.editor"); const { defaultLocale, files, header, markHeaderRendered } = useContentForm(); diff --git a/plugins/example/src/pages/event-page.tsx b/plugins/example/src/pages/event-page.tsx index 3674bfc93a..f04b0d2a44 100644 --- a/plugins/example/src/pages/event-page.tsx +++ b/plugins/example/src/pages/event-page.tsx @@ -1,4 +1,5 @@ -import { EditorContent } from "@vitnode/core/components/ui/editor-content"; +import { RichTextContent } from "@vitnode/core/components/rich-text"; +import { zodRichTextDocument } from "@vitnode/core/content"; import { definePluginRoute, type PluginRoutePageProps, @@ -15,7 +16,7 @@ import { ContentArticle } from "./content-article"; // `id` is a decimal string: `example.event` uses the `bigint` id strategy, and // an identifier above 2^53 would not survive a round trip through a number. const zodEvent = z.object({ - body: z.string(), + body: zodRichTextDocument.nullable(), id: z.string(), publishedAt: z.string().nullable(), sessions: z.array( @@ -70,9 +71,12 @@ const EventPage = ({ publishedAt={item.publishedAt} title={item.title} > -
- -
+ {item.body ? ( + + ) : null} {item.sessions.length > 0 ? (
Date: Sun, 11 Oct 2026 09:36:17 +0000 Subject: [PATCH 11/17] =?UTF-8?q?feat(api):=20=F0=9F=97=83=EF=B8=8F=20migr?= =?UTF-8?q?ation=20for=20article=20visibility=20and=20content=20id=20strat?= =?UTF-8?q?egies?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds blog_posts.hiddenAt/hiddenBy (FK core_users ON DELETE SET NULL, indexed); stores the content item key of every shared content table (revisions, schedules, slug history, search index, live editing locks, drafts and documents, AI translation sources) as varchar(64), cast in place so serial keys keep their digits; and adds the example plugin's uuid tags and bigint events. No other blog changes. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VpG3D33ZqXEYjN2N6eNcMK --- .../migration.sql | 170 + .../snapshot.json | 16996 ++++++++++++++++ 2 files changed, 17166 insertions(+) create mode 100644 apps/api/migrations/20261011093549_content_visibility_id_strategies/migration.sql create mode 100644 apps/api/migrations/20261011093549_content_visibility_id_strategies/snapshot.json diff --git a/apps/api/migrations/20261011093549_content_visibility_id_strategies/migration.sql b/apps/api/migrations/20261011093549_content_visibility_id_strategies/migration.sql new file mode 100644 index 0000000000..4f8aa6be5e --- /dev/null +++ b/apps/api/migrations/20261011093549_content_visibility_id_strategies/migration.sql @@ -0,0 +1,170 @@ +CREATE TABLE "example_advanced_articles_tags" ( + "itemId" integer, + "relatedItemId" uuid, + "position" integer NOT NULL, + "createdAt" timestamp DEFAULT now() NOT NULL, + CONSTRAINT "example_advanced_articles_tags_pk" PRIMARY KEY("itemId","relatedItemId") +); +--> statement-breakpoint +ALTER TABLE "example_advanced_articles_tags" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint +CREATE TABLE "example_events" ( + "id" bigint PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY (sequence name "example_events_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 9223372036854775807 START WITH 1 CACHE 1), + "createdAt" timestamp DEFAULT now() NOT NULL, + "updatedAt" timestamp DEFAULT now() NOT NULL, + "publishedAt" timestamp, + "status" varchar(32) DEFAULT 'draft' NOT NULL, + "version" integer DEFAULT 1 NOT NULL, + "hiddenAt" timestamp, + "hiddenBy" integer, + "primaryTag" uuid, + "category" integer +); +--> statement-breakpoint +ALTER TABLE "example_events" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint +CREATE TABLE "example_events_gallery" ( + "itemId" bigint, + "relatedItemId" integer, + "position" integer NOT NULL, + "createdAt" timestamp DEFAULT now() NOT NULL, + CONSTRAINT "example_events_gallery_pk" PRIMARY KEY("itemId","relatedItemId") +); +--> statement-breakpoint +ALTER TABLE "example_events_gallery" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint +CREATE TABLE "example_events_related_events" ( + "itemId" bigint, + "relatedItemId" bigint, + "position" integer NOT NULL, + "createdAt" timestamp DEFAULT now() NOT NULL, + CONSTRAINT "example_events_related_events_pk" PRIMARY KEY("itemId","relatedItemId") +); +--> statement-breakpoint +ALTER TABLE "example_events_related_events" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint +CREATE TABLE "example_events_sessions" ( + "id" serial PRIMARY KEY, + "itemId" bigint NOT NULL, + "position" integer NOT NULL, + "createdAt" timestamp DEFAULT now() NOT NULL, + "updatedAt" timestamp DEFAULT now() NOT NULL, + "title" varchar(200) NOT NULL, + "startsAt" timestamp +); +--> statement-breakpoint +ALTER TABLE "example_events_sessions" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint +CREATE TABLE "example_events_tags" ( + "itemId" bigint, + "relatedItemId" uuid, + "position" integer NOT NULL, + "createdAt" timestamp DEFAULT now() NOT NULL, + CONSTRAINT "example_events_tags_pk" PRIMARY KEY("itemId","relatedItemId") +); +--> statement-breakpoint +ALTER TABLE "example_events_tags" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint +CREATE TABLE "example_events_translations" ( + "itemId" bigint, + "languageId" integer, + "version" integer DEFAULT 1 NOT NULL, + "createdAt" timestamp DEFAULT now() NOT NULL, + "updatedAt" timestamp DEFAULT now() NOT NULL, + "publishedAt" timestamp, + "status" varchar(32) DEFAULT 'draft' NOT NULL, + "title" varchar(200) NOT NULL, + "slug" varchar(160) NOT NULL, + "body" jsonb, + CONSTRAINT "example_events_translations_item_id_language_id_pk" PRIMARY KEY("itemId","languageId") +); +--> statement-breakpoint +ALTER TABLE "example_events_translations" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint +CREATE TABLE "example_tags" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "createdAt" timestamp DEFAULT now() NOT NULL, + "updatedAt" timestamp DEFAULT now() NOT NULL, + "publishedAt" timestamp, + "status" varchar(32) DEFAULT 'draft' NOT NULL, + "version" integer DEFAULT 1 NOT NULL, + "hiddenAt" timestamp, + "hiddenBy" integer, + "category" integer +); +--> statement-breakpoint +ALTER TABLE "example_tags" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint +CREATE TABLE "example_tags_related_tags" ( + "itemId" uuid, + "relatedItemId" uuid, + "position" integer NOT NULL, + "createdAt" timestamp DEFAULT now() NOT NULL, + CONSTRAINT "example_tags_related_tags_pk" PRIMARY KEY("itemId","relatedItemId") +); +--> statement-breakpoint +ALTER TABLE "example_tags_related_tags" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint +CREATE TABLE "example_tags_translations" ( + "itemId" uuid, + "languageId" integer, + "version" integer DEFAULT 1 NOT NULL, + "createdAt" timestamp DEFAULT now() NOT NULL, + "updatedAt" timestamp DEFAULT now() NOT NULL, + "publishedAt" timestamp, + "status" varchar(32) DEFAULT 'draft' NOT NULL, + "name" varchar(100) NOT NULL, + "slug" varchar(160) NOT NULL, + CONSTRAINT "example_tags_translations_item_id_language_id_pk" PRIMARY KEY("itemId","languageId") +); +--> statement-breakpoint +ALTER TABLE "example_tags_translations" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint +ALTER TABLE "blog_posts" ADD COLUMN "hiddenAt" timestamp;--> statement-breakpoint +ALTER TABLE "blog_posts" ADD COLUMN "hiddenBy" integer;--> statement-breakpoint +ALTER TABLE "core_ai_translation_sources" ALTER COLUMN "itemId" SET DATA TYPE varchar(64) USING "itemId"::varchar(64);--> statement-breakpoint +ALTER TABLE "core_content_documents" ALTER COLUMN "itemId" SET DATA TYPE varchar(64) USING "itemId"::varchar(64);--> statement-breakpoint +ALTER TABLE "core_content_drafts" ALTER COLUMN "itemId" SET DATA TYPE varchar(64) USING "itemId"::varchar(64);--> statement-breakpoint +ALTER TABLE "core_content_field_locks" ALTER COLUMN "itemId" SET DATA TYPE varchar(64) USING "itemId"::varchar(64);--> statement-breakpoint +ALTER TABLE "core_content_revisions" ALTER COLUMN "itemId" SET DATA TYPE varchar(64) USING "itemId"::varchar(64);--> statement-breakpoint +ALTER TABLE "core_content_schedules" ALTER COLUMN "itemId" SET DATA TYPE varchar(64) USING "itemId"::varchar(64);--> statement-breakpoint +ALTER TABLE "core_content_slug_history" ALTER COLUMN "itemId" SET DATA TYPE varchar(64) USING "itemId"::varchar(64);--> statement-breakpoint +ALTER TABLE "core_search_index" ALTER COLUMN "itemId" SET DATA TYPE varchar(64) USING "itemId"::varchar(64);--> statement-breakpoint +CREATE INDEX "blog_posts_hidden_by_idx" ON "blog_posts" ("hiddenBy");--> statement-breakpoint +CREATE UNIQUE INDEX "example_advanced_articles_tags_position_key" ON "example_advanced_articles_tags" ("itemId","position");--> statement-breakpoint +CREATE INDEX "example_advanced_articles_tags_related_item_id_idx" ON "example_advanced_articles_tags" ("relatedItemId");--> statement-breakpoint +CREATE INDEX "example_events_primary_tag_idx" ON "example_events" ("primaryTag");--> statement-breakpoint +CREATE INDEX "example_events_category_idx" ON "example_events" ("category");--> statement-breakpoint +CREATE INDEX "example_events_created_at_idx" ON "example_events" ("createdAt");--> statement-breakpoint +CREATE INDEX "example_events_updated_at_idx" ON "example_events" ("updatedAt");--> statement-breakpoint +CREATE INDEX "example_events_status_published_at_idx" ON "example_events" ("status","publishedAt");--> statement-breakpoint +CREATE INDEX "example_events_hidden_by_idx" ON "example_events" ("hiddenBy");--> statement-breakpoint +CREATE UNIQUE INDEX "example_events_gallery_position_key" ON "example_events_gallery" ("itemId","position");--> statement-breakpoint +CREATE INDEX "example_events_gallery_related_item_id_idx" ON "example_events_gallery" ("relatedItemId");--> statement-breakpoint +CREATE UNIQUE INDEX "example_events_related_events_position_key" ON "example_events_related_events" ("itemId","position");--> statement-breakpoint +CREATE INDEX "example_events_related_events_related_item_id_idx" ON "example_events_related_events" ("relatedItemId");--> statement-breakpoint +CREATE UNIQUE INDEX "example_events_sessions_position_key" ON "example_events_sessions" ("itemId","position");--> statement-breakpoint +CREATE UNIQUE INDEX "example_events_tags_position_key" ON "example_events_tags" ("itemId","position");--> statement-breakpoint +CREATE INDEX "example_events_tags_related_item_id_idx" ON "example_events_tags" ("relatedItemId");--> statement-breakpoint +CREATE INDEX "example_events_translations_language_id_status_idx" ON "example_events_translations" ("languageId","status");--> statement-breakpoint +CREATE UNIQUE INDEX "example_events_translations_language_id_slug_key" ON "example_events_translations" ("languageId","slug");--> statement-breakpoint +CREATE INDEX "example_tags_category_idx" ON "example_tags" ("category");--> statement-breakpoint +CREATE INDEX "example_tags_created_at_idx" ON "example_tags" ("createdAt");--> statement-breakpoint +CREATE INDEX "example_tags_updated_at_idx" ON "example_tags" ("updatedAt");--> statement-breakpoint +CREATE INDEX "example_tags_status_published_at_idx" ON "example_tags" ("status","publishedAt");--> statement-breakpoint +CREATE INDEX "example_tags_hidden_by_idx" ON "example_tags" ("hiddenBy");--> statement-breakpoint +CREATE UNIQUE INDEX "example_tags_related_tags_position_key" ON "example_tags_related_tags" ("itemId","position");--> statement-breakpoint +CREATE INDEX "example_tags_related_tags_related_item_id_idx" ON "example_tags_related_tags" ("relatedItemId");--> statement-breakpoint +CREATE INDEX "example_tags_translations_language_id_status_idx" ON "example_tags_translations" ("languageId","status");--> statement-breakpoint +CREATE UNIQUE INDEX "example_tags_translations_language_id_slug_key" ON "example_tags_translations" ("languageId","slug");--> statement-breakpoint +ALTER TABLE "blog_posts" ADD CONSTRAINT "blog_posts_hiddenBy_core_users_id_fkey" FOREIGN KEY ("hiddenBy") REFERENCES "core_users"("id") ON DELETE SET NULL ON UPDATE CASCADE;--> statement-breakpoint +ALTER TABLE "example_advanced_articles_tags" ADD CONSTRAINT "example_advanced_articles_tags_ATchHLRtvoCM_fkey" FOREIGN KEY ("itemId") REFERENCES "example_advanced_articles"("id") ON DELETE CASCADE ON UPDATE CASCADE;--> statement-breakpoint +ALTER TABLE "example_advanced_articles_tags" ADD CONSTRAINT "example_advanced_articles_tags_dEsWdkDmu7nW_fkey" FOREIGN KEY ("relatedItemId") REFERENCES "example_tags"("id") ON DELETE RESTRICT ON UPDATE CASCADE;--> statement-breakpoint +ALTER TABLE "example_events" ADD CONSTRAINT "example_events_hiddenBy_core_users_id_fkey" FOREIGN KEY ("hiddenBy") REFERENCES "core_users"("id") ON DELETE SET NULL ON UPDATE CASCADE;--> statement-breakpoint +ALTER TABLE "example_events" ADD CONSTRAINT "example_events_primaryTag_example_tags_id_fkey" FOREIGN KEY ("primaryTag") REFERENCES "example_tags"("id") ON DELETE SET NULL ON UPDATE CASCADE;--> statement-breakpoint +ALTER TABLE "example_events" ADD CONSTRAINT "example_events_category_example_categories_id_fkey" FOREIGN KEY ("category") REFERENCES "example_categories"("id") ON DELETE SET NULL ON UPDATE CASCADE;--> statement-breakpoint +ALTER TABLE "example_events_gallery" ADD CONSTRAINT "example_events_gallery_itemId_example_events_id_fkey" FOREIGN KEY ("itemId") REFERENCES "example_events"("id") ON DELETE CASCADE ON UPDATE CASCADE;--> statement-breakpoint +ALTER TABLE "example_events_gallery" ADD CONSTRAINT "example_events_gallery_relatedItemId_core_files_id_fkey" FOREIGN KEY ("relatedItemId") REFERENCES "core_files"("id") ON DELETE RESTRICT ON UPDATE CASCADE;--> statement-breakpoint +ALTER TABLE "example_events_related_events" ADD CONSTRAINT "example_events_related_events_itemId_example_events_id_fkey" FOREIGN KEY ("itemId") REFERENCES "example_events"("id") ON DELETE CASCADE ON UPDATE CASCADE;--> statement-breakpoint +ALTER TABLE "example_events_related_events" ADD CONSTRAINT "example_events_related_events_oaRvqnKyAS7L_fkey" FOREIGN KEY ("relatedItemId") REFERENCES "example_events"("id") ON DELETE CASCADE ON UPDATE CASCADE;--> statement-breakpoint +ALTER TABLE "example_events_sessions" ADD CONSTRAINT "example_events_sessions_itemId_example_events_id_fkey" FOREIGN KEY ("itemId") REFERENCES "example_events"("id") ON DELETE CASCADE ON UPDATE CASCADE;--> statement-breakpoint +ALTER TABLE "example_events_tags" ADD CONSTRAINT "example_events_tags_itemId_example_events_id_fkey" FOREIGN KEY ("itemId") REFERENCES "example_events"("id") ON DELETE CASCADE ON UPDATE CASCADE;--> statement-breakpoint +ALTER TABLE "example_events_tags" ADD CONSTRAINT "example_events_tags_relatedItemId_example_tags_id_fkey" FOREIGN KEY ("relatedItemId") REFERENCES "example_tags"("id") ON DELETE RESTRICT ON UPDATE CASCADE;--> statement-breakpoint +ALTER TABLE "example_events_translations" ADD CONSTRAINT "example_events_translations_itemId_example_events_id_fkey" FOREIGN KEY ("itemId") REFERENCES "example_events"("id") ON DELETE CASCADE ON UPDATE CASCADE;--> statement-breakpoint +ALTER TABLE "example_events_translations" ADD CONSTRAINT "example_events_translations_languageId_core_languages_id_fkey" FOREIGN KEY ("languageId") REFERENCES "core_languages"("id") ON DELETE RESTRICT ON UPDATE CASCADE;--> statement-breakpoint +ALTER TABLE "example_tags" ADD CONSTRAINT "example_tags_hiddenBy_core_users_id_fkey" FOREIGN KEY ("hiddenBy") REFERENCES "core_users"("id") ON DELETE SET NULL ON UPDATE CASCADE;--> statement-breakpoint +ALTER TABLE "example_tags" ADD CONSTRAINT "example_tags_category_example_categories_id_fkey" FOREIGN KEY ("category") REFERENCES "example_categories"("id") ON DELETE SET NULL ON UPDATE CASCADE;--> statement-breakpoint +ALTER TABLE "example_tags_related_tags" ADD CONSTRAINT "example_tags_related_tags_itemId_example_tags_id_fkey" FOREIGN KEY ("itemId") REFERENCES "example_tags"("id") ON DELETE CASCADE ON UPDATE CASCADE;--> statement-breakpoint +ALTER TABLE "example_tags_related_tags" ADD CONSTRAINT "example_tags_related_tags_relatedItemId_example_tags_id_fkey" FOREIGN KEY ("relatedItemId") REFERENCES "example_tags"("id") ON DELETE CASCADE ON UPDATE CASCADE;--> statement-breakpoint +ALTER TABLE "example_tags_translations" ADD CONSTRAINT "example_tags_translations_itemId_example_tags_id_fkey" FOREIGN KEY ("itemId") REFERENCES "example_tags"("id") ON DELETE CASCADE ON UPDATE CASCADE;--> statement-breakpoint +ALTER TABLE "example_tags_translations" ADD CONSTRAINT "example_tags_translations_languageId_core_languages_id_fkey" FOREIGN KEY ("languageId") REFERENCES "core_languages"("id") ON DELETE RESTRICT ON UPDATE CASCADE; \ No newline at end of file diff --git a/apps/api/migrations/20261011093549_content_visibility_id_strategies/snapshot.json b/apps/api/migrations/20261011093549_content_visibility_id_strategies/snapshot.json new file mode 100644 index 0000000000..a97e2e39d0 --- /dev/null +++ b/apps/api/migrations/20261011093549_content_visibility_id_strategies/snapshot.json @@ -0,0 +1,16996 @@ +{ + "version": "8", + "dialect": "postgres", + "id": "680724d8-a62f-48ca-aa75-7e3f07114a50", + "prevIds": [ + "f56f3fa4-bc19-468a-9bb0-04e398dfc3d4" + ], + "ddl": [ + { + "isRlsEnabled": true, + "name": "core_admin_permissions", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_admin_sessions", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_ai_action_settings", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_ai_budget_periods", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_ai_calls", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_ai_cost_adjustments", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_ai_reservations", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_ai_role_permissions", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_ai_role_policies", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_ai_runs", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_ai_settings", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_ai_translation_sources", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_ai_user_overrides", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_content_documents", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_content_drafts", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_content_field_locks", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_content_file_refs", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_content_revisions", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_content_schedules", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_content_slug_history", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_cron", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_admin_dashboard", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_files", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_files_alt", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_files_alt_analysis", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_files_alt_state", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_languages", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_languages_words", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_logs", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_moderators_permissions", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_navigation", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_notification_deliveries", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_notification_events", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_notification_receipts", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_notification_settings", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_notification_user_state", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_notifications", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_page_layouts", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users_passkey_challenges", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users_passkeys", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_queue", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_roles", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_search_index", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_secrets", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_sessions", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_sessions_known_devices", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users_confirm_emails", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users_forgot_password", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users_secondary_roles", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users_sso", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users_sso_operations", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users_sso_profile_sources", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "blog_categories", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "blog_categories_translations", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "blog_posts", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "blog_posts_author_id", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "blog_posts_category_id", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "blog_posts_translations", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_advanced_articles", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_advanced_articles_categories", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_advanced_articles_faq", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_advanced_articles_related_articles", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_advanced_articles_tags", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_advanced_articles_translations", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_articles", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_articles_gallery", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_categories", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_events", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_events_gallery", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_events_related_events", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_events_sessions", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_events_tags", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_events_translations", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_localized_articles", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_localized_articles_translations", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_pages", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_tags", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_tags_related_tags", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_tags_translations", + "entityType": "tables", + "schema": "public" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "roleId", + "entityType": "columns", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "protected", + "entityType": "columns", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "unrestricted", + "entityType": "columns", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "permissions", + "entityType": "columns", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "token", + "entityType": "columns", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "lastSeen", + "entityType": "columns", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expiresAt", + "entityType": "columns", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "deviceId", + "entityType": "columns", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "actionKey", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "enabled", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "modelId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "fallbackModelId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "maxInputCharacters", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "maxOutputTokens", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "timeoutMs", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "maxRetries", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "maxSteps", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "dailyLimit", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "instructions", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_ai_budget_periods" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "scopeKey", + "entityType": "columns", + "schema": "public", + "table": "core_ai_budget_periods" + }, + { + "type": "varchar(10)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "unit", + "entityType": "columns", + "schema": "public", + "table": "core_ai_budget_periods" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "periodStart", + "entityType": "columns", + "schema": "public", + "table": "core_ai_budget_periods" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "periodEnd", + "entityType": "columns", + "schema": "public", + "table": "core_ai_budget_periods" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "limitAmount", + "entityType": "columns", + "schema": "public", + "table": "core_ai_budget_periods" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'0'", + "generated": null, + "identity": null, + "name": "spentAmount", + "entityType": "columns", + "schema": "public", + "table": "core_ai_budget_periods" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'0'", + "generated": null, + "identity": null, + "name": "reservedAmount", + "entityType": "columns", + "schema": "public", + "table": "core_ai_budget_periods" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "unknownCount", + "entityType": "columns", + "schema": "public", + "table": "core_ai_budget_periods" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_budget_periods" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "runId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "attempt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "modelId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "provider", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerModelId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerRequestId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "errorCode", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "inputTokens", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "outputTokens", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "cacheReadTokens", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "cacheWriteTokens", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "reasoningTokens", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "images", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "costUsd", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "costSource", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "costReason", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pricingVersion", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pricingSnapshot", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "startedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "finishedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "reconciledAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "callId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "runId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "previousCostUsd", + "entityType": "columns", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "previousSource", + "entityType": "columns", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "newCostUsd", + "entityType": "columns", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "newSource", + "entityType": "columns", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "deltaUsd", + "entityType": "columns", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "reason", + "entityType": "columns", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_ai_reservations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "runId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_reservations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "budgetPeriodId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_reservations" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "amount", + "entityType": "columns", + "schema": "public", + "table": "core_ai_reservations" + }, + { + "type": "varchar(10)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'active'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "core_ai_reservations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_reservations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "settledAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_reservations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "roleId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_role_permissions" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "permission", + "entityType": "columns", + "schema": "public", + "table": "core_ai_role_permissions" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "granted", + "entityType": "columns", + "schema": "public", + "table": "core_ai_role_permissions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "dailyLimit", + "entityType": "columns", + "schema": "public", + "table": "core_ai_role_permissions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "roleId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_role_policies" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "monthlyPoints", + "entityType": "columns", + "schema": "public", + "table": "core_ai_role_policies" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "unlimited", + "entityType": "columns", + "schema": "public", + "table": "core_ai_role_policies" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_role_policies" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "actionKey", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(10)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "actorType", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "idempotencyKey", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "resourceType", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "resourceId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "errorCode", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "promptVersion", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pointsConversionVersion", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "modelId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "provider", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerModelId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "inputTokens", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "outputTokens", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "cacheReadTokens", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "cacheWriteTokens", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "reasoningTokens", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "costUsd", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "costSource", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "chargedUsd", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "chargedPoints", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'0'", + "generated": null, + "identity": null, + "name": "reservedUsd", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'0'", + "generated": null, + "identity": null, + "name": "reservedPoints", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'pending'", + "generated": null, + "identity": null, + "name": "settlement", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "leaseExpiresAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "accepted", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "acceptedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "sourceFingerprint", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "startedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "finishedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "settledAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "enabled", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "monthlyBudgetUsd", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "systemMonthlyBudgetUsd", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'0'", + "generated": null, + "identity": null, + "name": "defaultMonthlyPoints", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "pointsConversionVersion", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "10", + "generated": null, + "identity": null, + "name": "userRequestsPerMinute", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "2", + "generated": null, + "identity": null, + "name": "userConcurrency", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "2", + "generated": null, + "identity": null, + "name": "systemConcurrency", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "365", + "generated": null, + "identity": null, + "name": "historyRetentionDays", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "altEnabled", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "10", + "generated": null, + "identity": null, + "name": "altBatchSize", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "altScanCursor", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "altScanStartedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_ai_translation_sources" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "contentTypeId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_translation_sources" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_translation_sources" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "locale", + "entityType": "columns", + "schema": "public", + "table": "core_ai_translation_sources" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "field", + "entityType": "columns", + "schema": "public", + "table": "core_ai_translation_sources" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "sourceLocale", + "entityType": "columns", + "schema": "public", + "table": "core_ai_translation_sources" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "sourceFingerprint", + "entityType": "columns", + "schema": "public", + "table": "core_ai_translation_sources" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "targetFingerprint", + "entityType": "columns", + "schema": "public", + "table": "core_ai_translation_sources" + }, + { + "type": "varchar(10)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "origin", + "entityType": "columns", + "schema": "public", + "table": "core_ai_translation_sources" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updatedById", + "entityType": "columns", + "schema": "public", + "table": "core_ai_translation_sources" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_translation_sources" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_user_overrides" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "monthlyPoints", + "entityType": "columns", + "schema": "public", + "table": "core_ai_user_overrides" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "unlimited", + "entityType": "columns", + "schema": "public", + "table": "core_ai_user_overrides" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "blocked", + "entityType": "columns", + "schema": "public", + "table": "core_ai_user_overrides" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_user_overrides" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_content_documents" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "contentTypeId", + "entityType": "columns", + "schema": "public", + "table": "core_content_documents" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "core_content_documents" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "field", + "entityType": "columns", + "schema": "public", + "table": "core_content_documents" + }, + { + "type": "varchar(35)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "language", + "entityType": "columns", + "schema": "public", + "table": "core_content_documents" + }, + { + "type": "bytea", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "state", + "entityType": "columns", + "schema": "public", + "table": "core_content_documents" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "baseVersion", + "entityType": "columns", + "schema": "public", + "table": "core_content_documents" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_documents" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updatedBy", + "entityType": "columns", + "schema": "public", + "table": "core_content_documents" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_content_drafts" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "contentTypeId", + "entityType": "columns", + "schema": "public", + "table": "core_content_drafts" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "core_content_drafts" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "language", + "entityType": "columns", + "schema": "public", + "table": "core_content_drafts" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "values", + "entityType": "columns", + "schema": "public", + "table": "core_content_drafts" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "baseVersion", + "entityType": "columns", + "schema": "public", + "table": "core_content_drafts" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_drafts" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updatedBy", + "entityType": "columns", + "schema": "public", + "table": "core_content_drafts" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_content_field_locks" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "contentTypeId", + "entityType": "columns", + "schema": "public", + "table": "core_content_field_locks" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "core_content_field_locks" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "field", + "entityType": "columns", + "schema": "public", + "table": "core_content_field_locks" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "language", + "entityType": "columns", + "schema": "public", + "table": "core_content_field_locks" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_content_field_locks" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "acquiredAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_field_locks" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expiresAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_field_locks" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_content_file_refs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "revisionId", + "entityType": "columns", + "schema": "public", + "table": "core_content_file_refs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "fileId", + "entityType": "columns", + "schema": "public", + "table": "core_content_file_refs" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_file_refs" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "contentTypeId", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "languageId", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "operation", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "snapshot", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "changedFields", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'system'", + "generated": null, + "identity": null, + "name": "actorType", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "actorUserId", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "restoredFromRevisionId", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "contentTypeId", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "action", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "scheduledFor", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "generation", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'pending'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "createdBy", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "completedAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastError", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "effectsError", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "contentTypeId", + "entityType": "columns", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "languageId", + "entityType": "columns", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "type": "varchar(512)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "path", + "entityType": "columns", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "retiredAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_cron" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "core_cron" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "description", + "entityType": "columns", + "schema": "public", + "table": "core_cron" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastRun", + "entityType": "columns", + "schema": "public", + "table": "core_cron" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_cron" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_cron" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "module", + "entityType": "columns", + "schema": "public", + "table": "core_cron" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "nextRun", + "entityType": "columns", + "schema": "public", + "table": "core_cron" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "schedule", + "entityType": "columns", + "schema": "public", + "table": "core_cron" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_admin_dashboard" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_admin_dashboard" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "widgets", + "entityType": "columns", + "schema": "public", + "table": "core_admin_dashboard" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_admin_dashboard" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_admin_dashboard" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "varchar(512)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "key", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "folder", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "mimeType", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "size", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "metadata", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "fingerprint", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'automatic'", + "generated": null, + "identity": null, + "name": "altPolicy", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "fileId", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "languageCode", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "text", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt" + }, + { + "type": "varchar(10)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "origin", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "fileFingerprint", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "runId", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updatedById", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt_analysis" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "fileId", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt_analysis" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "fileFingerprint", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt_analysis" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "description", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt_analysis" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "runId", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt_analysis" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt_analysis" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "fileId", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt_state" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt_state" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastError", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt_state" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt_state" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_languages" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "code", + "entityType": "columns", + "schema": "public", + "table": "core_languages" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "core_languages" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'UTC'", + "generated": null, + "identity": null, + "name": "timezone", + "entityType": "columns", + "schema": "public", + "table": "core_languages" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "protected", + "entityType": "columns", + "schema": "public", + "table": "core_languages" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "default", + "entityType": "columns", + "schema": "public", + "table": "core_languages" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_languages" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_languages" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "time24", + "entityType": "columns", + "schema": "public", + "table": "core_languages" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_languages_words" + }, + { + "type": "varchar", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "languageCode", + "entityType": "columns", + "schema": "public", + "table": "core_languages_words" + }, + { + "type": "varchar(50)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginCode", + "entityType": "columns", + "schema": "public", + "table": "core_languages_words" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "core_languages_words" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "value", + "entityType": "columns", + "schema": "public", + "table": "core_languages_words" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "tableName", + "entityType": "columns", + "schema": "public", + "table": "core_languages_words" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "variable", + "entityType": "columns", + "schema": "public", + "table": "core_languages_words" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "varchar(10)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "type", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "content", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "varchar(45)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ipAddress", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "varchar(10)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'GET'", + "generated": null, + "identity": null, + "name": "method", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'localhost'", + "generated": null, + "identity": null, + "name": "path", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userAgent", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "500", + "generated": null, + "identity": null, + "name": "statusCode", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "roleId", + "entityType": "columns", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "protected", + "entityType": "columns", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "unrestricted", + "entityType": "columns", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "permissions", + "entityType": "columns", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "parentId", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'header'", + "generated": null, + "identity": null, + "name": "location", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "kind", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "varchar(50)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "varchar(120)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "presetId", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "href", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "icon", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "isOpenInNewTab", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "bigserial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "channel", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "mode", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "eventId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "idempotencyKey", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'pending'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "attempts", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "5", + "generated": null, + "identity": null, + "name": "maxAttempts", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "availableAt", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "periodStart", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "periodEnd", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "itemCount", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerMessageId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "varchar(500)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastError", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "varchar(50)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "skipReason", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "sentAt", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "bigserial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "type", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "schemaVersion", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "idempotencyKey", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "actorId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "subjectType", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "subjectId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "groupKey", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "data", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 1, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "recipientIds", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "allowSelf", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'pending'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "recipientCursor", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "deliveredCount", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastError", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "completedAt", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "eventId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "notificationId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "seq", + "entityType": "columns", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "emailPending", + "entityType": "columns", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "emailDeliveryId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "key", + "entityType": "columns", + "schema": "public", + "table": "core_notification_settings" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "value", + "entityType": "columns", + "schema": "public", + "table": "core_notification_settings" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_notification_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_user_state" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "unreadCount", + "entityType": "columns", + "schema": "public", + "table": "core_notification_user_state" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "revision", + "entityType": "columns", + "schema": "public", + "table": "core_notification_user_state" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "preferences", + "entityType": "columns", + "schema": "public", + "table": "core_notification_user_state" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_notification_user_state" + }, + { + "type": "bigserial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "type", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "varchar(50)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "category", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "groupKey", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "groupBucket", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "subjectType", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "subjectId", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "latestEventId", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "eventCount", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "activitySeq", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "readSeq", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "readAt", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "archivedAt", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "lastActivityAt", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "varchar(120)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pageId", + "entityType": "columns", + "schema": "public", + "table": "core_page_layouts" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "zones", + "entityType": "columns", + "schema": "public", + "table": "core_page_layouts" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_page_layouts" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_page_layouts" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "tokenHash", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ceremony", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "varchar(128)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "challenge", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "varchar(128)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "webauthnUserId", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expiresAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "varchar(1024)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "credentialId", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publicKey", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "counter", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "varchar(128)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "webauthnUserId", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 1, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "transports", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "deviceType", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "backedUp", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "varchar(36)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "aaguid", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastUsedAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'default'", + "generated": null, + "identity": null, + "name": "queue", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'pending'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "payload", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "priority", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "attempts", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "3", + "generated": null, + "identity": null, + "name": "maxAttempts", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "availableAt", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "reservedAt", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastError", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "completedAt", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "dedupeKey", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "protected", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "default", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "root", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "guest", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "varchar(50)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "color", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "prefix", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "allowUploadFiles", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "totalMaxStorage", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "maxStorageForSubmit", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "allowUploadAvatar", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "2048", + "generated": null, + "identity": null, + "name": "maxAvatarSize", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "allowUploadCover", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "5120", + "generated": null, + "identity": null, + "name": "maxCoverSize", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "allowEditPersonalInfo", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemType", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "languageCode", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 1, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "authorIds", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "content", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "tsvector", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": { + "as": "setweight(to_tsvector(CASE lower(split_part(\"core_search_index\".\"languageCode\", '-', 1)) WHEN 'da' THEN 'danish'::regconfig WHEN 'de' THEN 'german'::regconfig WHEN 'en' THEN 'english'::regconfig WHEN 'es' THEN 'spanish'::regconfig WHEN 'fi' THEN 'finnish'::regconfig WHEN 'fr' THEN 'french'::regconfig WHEN 'hu' THEN 'hungarian'::regconfig WHEN 'it' THEN 'italian'::regconfig WHEN 'nl' THEN 'dutch'::regconfig WHEN 'no' THEN 'norwegian'::regconfig WHEN 'pl' THEN 'polish'::regconfig WHEN 'pt' THEN 'portuguese'::regconfig WHEN 'ro' THEN 'romanian'::regconfig WHEN 'ru' THEN 'russian'::regconfig WHEN 'sv' THEN 'swedish'::regconfig WHEN 'tr' THEN 'turkish'::regconfig ELSE 'simple'::regconfig END, coalesce(\"core_search_index\".\"title\", '')), 'A') || setweight(to_tsvector(CASE lower(split_part(\"core_search_index\".\"languageCode\", '-', 1)) WHEN 'da' THEN 'danish'::regconfig WHEN 'de' THEN 'german'::regconfig WHEN 'en' THEN 'english'::regconfig WHEN 'es' THEN 'spanish'::regconfig WHEN 'fi' THEN 'finnish'::regconfig WHEN 'fr' THEN 'french'::regconfig WHEN 'hu' THEN 'hungarian'::regconfig WHEN 'it' THEN 'italian'::regconfig WHEN 'nl' THEN 'dutch'::regconfig WHEN 'no' THEN 'norwegian'::regconfig WHEN 'pl' THEN 'polish'::regconfig WHEN 'pt' THEN 'portuguese'::regconfig WHEN 'ro' THEN 'romanian'::regconfig WHEN 'ru' THEN 'russian'::regconfig WHEN 'sv' THEN 'swedish'::regconfig WHEN 'tr' THEN 'turkish'::regconfig ELSE 'simple'::regconfig END, coalesce(\"core_search_index\".\"content\", '')), 'B')", + "type": "stored" + }, + "identity": null, + "name": "search_vector", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "containerType", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "containerId", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "url", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "isPublic", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "metadata", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "indexedAt", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "core_secrets" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "value", + "entityType": "columns", + "schema": "public", + "table": "core_secrets" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_secrets" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_sessions" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "token", + "entityType": "columns", + "schema": "public", + "table": "core_sessions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_sessions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_sessions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expiresAt", + "entityType": "columns", + "schema": "public", + "table": "core_sessions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "deviceId", + "entityType": "columns", + "schema": "public", + "table": "core_sessions" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_sessions_known_devices" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publicId", + "entityType": "columns", + "schema": "public", + "table": "core_sessions_known_devices" + }, + { + "type": "varchar(40)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ipAddress", + "entityType": "columns", + "schema": "public", + "table": "core_sessions_known_devices" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userAgent", + "entityType": "columns", + "schema": "public", + "table": "core_sessions_known_devices" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "lastSeen", + "entityType": "columns", + "schema": "public", + "table": "core_sessions_known_devices" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "nameCode", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "email", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(128)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "firstName", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(128)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastName", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "phone", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "headline", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "showRealName", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "password", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "newsletter", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(6)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "avatarColor", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "emailVerified", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "roleId", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "birthday", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "timeZone", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(40)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ipAddress", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'en'", + "generated": null, + "identity": null, + "name": "language", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "avatarId", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "coverId", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_users_confirm_emails" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_users_confirm_emails" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "token", + "entityType": "columns", + "schema": "public", + "table": "core_users_confirm_emails" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_confirm_emails" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expiresAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_confirm_emails" + }, + { + "type": "varchar(40)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ipAddress", + "entityType": "columns", + "schema": "public", + "table": "core_users_confirm_emails" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_users_forgot_password" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_users_forgot_password" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "token", + "entityType": "columns", + "schema": "public", + "table": "core_users_forgot_password" + }, + { + "type": "varchar(40)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ipAddress", + "entityType": "columns", + "schema": "public", + "table": "core_users_forgot_password" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_forgot_password" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expiresAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_forgot_password" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_users_secondary_roles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "roleId", + "entityType": "columns", + "schema": "public", + "table": "core_users_secondary_roles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_secondary_roles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerAccountId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerEmail", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerUsername", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "syncOnSignIn", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "varchar(2048)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "avatarSourceUrl", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "avatarSha256", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "avatarFileId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "tokenHash", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "intent", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 1, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "fields", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerAccountId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "preview", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expiresAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_profile_sources" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "field", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_profile_sources" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_profile_sources" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_profile_sources" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "blog_categories" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "blog_categories" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "blog_categories" + }, + { + "type": "varchar(50)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "color", + "entityType": "columns", + "schema": "public", + "table": "blog_categories" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "languageId", + "entityType": "columns", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "blog_posts" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "blog_posts" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "blog_posts" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hiddenAt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hiddenBy", + "entityType": "columns", + "schema": "public", + "table": "blog_posts" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "coverImage", + "entityType": "columns", + "schema": "public", + "table": "blog_posts" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "relatedItemId", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "relatedItemId", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "languageId", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "friendlyUrl", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "content", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "excerpt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "coverImageAlt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "syndicationIndexable", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "syndicationNoIndex", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "5", + "generated": null, + "identity": null, + "name": "syndicationPriority", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_categories" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "relatedItemId", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_categories" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_categories" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_categories" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_faq" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_faq" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_faq" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_faq" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_faq" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "question", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_faq" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "answer", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_faq" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_related_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "relatedItemId", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_related_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_related_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_related_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_tags" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "relatedItemId", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_tags" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_tags" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_tags" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "languageId", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "seoTitle", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "seoDescription", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "code", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "excerpt", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "views", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "featured", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "noIndex", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "author", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "animation", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "category", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "example_articles_gallery" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "relatedItemId", + "entityType": "columns", + "schema": "public", + "table": "example_articles_gallery" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "example_articles_gallery" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_articles_gallery" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "example_categories" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_categories" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_categories" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "example_categories" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": { + "type": "byDefault", + "name": "example_events_id_seq", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": 1, + "cycle": false + }, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "example_events" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_events" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_events" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "example_events" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "example_events" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "example_events" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hiddenAt", + "entityType": "columns", + "schema": "public", + "table": "example_events" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hiddenBy", + "entityType": "columns", + "schema": "public", + "table": "example_events" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "primaryTag", + "entityType": "columns", + "schema": "public", + "table": "example_events" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "category", + "entityType": "columns", + "schema": "public", + "table": "example_events" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "example_events_gallery" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "relatedItemId", + "entityType": "columns", + "schema": "public", + "table": "example_events_gallery" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "example_events_gallery" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_events_gallery" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "example_events_related_events" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "relatedItemId", + "entityType": "columns", + "schema": "public", + "table": "example_events_related_events" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "example_events_related_events" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_events_related_events" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "example_events_sessions" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "example_events_sessions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "example_events_sessions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_events_sessions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_events_sessions" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "example_events_sessions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "startsAt", + "entityType": "columns", + "schema": "public", + "table": "example_events_sessions" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "example_events_tags" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "relatedItemId", + "entityType": "columns", + "schema": "public", + "table": "example_events_tags" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "example_events_tags" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_events_tags" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "example_events_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "languageId", + "entityType": "columns", + "schema": "public", + "table": "example_events_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "example_events_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_events_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_events_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "example_events_translations" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "example_events_translations" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "example_events_translations" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "example_events_translations" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "body", + "entityType": "columns", + "schema": "public", + "table": "example_events_translations" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "featured", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "languageId", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "body", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "example_pages" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_pages" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_pages" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "example_pages" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "example_pages" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "example_pages" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "example_pages" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "content", + "entityType": "columns", + "schema": "public", + "table": "example_pages" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "example_tags" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_tags" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_tags" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "example_tags" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "example_tags" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "example_tags" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hiddenAt", + "entityType": "columns", + "schema": "public", + "table": "example_tags" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hiddenBy", + "entityType": "columns", + "schema": "public", + "table": "example_tags" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "category", + "entityType": "columns", + "schema": "public", + "table": "example_tags" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "example_tags_related_tags" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "relatedItemId", + "entityType": "columns", + "schema": "public", + "table": "example_tags_related_tags" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "example_tags_related_tags" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_tags_related_tags" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "example_tags_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "languageId", + "entityType": "columns", + "schema": "public", + "table": "example_tags_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "example_tags_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_tags_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_tags_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "example_tags_translations" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "example_tags_translations" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "example_tags_translations" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "example_tags_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "roleId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_admin_permissions_role_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_admin_permissions_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_admin_permissions_updated_at_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_admin_sessions_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "deviceId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_admin_sessions_device_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "scopeKey", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "periodStart", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_ai_budget_periods_scope_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_ai_budget_periods" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "runId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_ai_calls_run_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_ai_calls" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "costSource", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "reconciledAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_ai_calls_reconcile_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_ai_calls" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "callId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_ai_cost_adjustments_call_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "runId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "budgetPeriodId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_ai_reservations_run_period_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_ai_reservations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_ai_reservations_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_ai_reservations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_ai_runs_user_created_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_ai_runs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_ai_runs_created_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_ai_runs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "actionKey", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_ai_runs_action_created_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_ai_runs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "leaseExpiresAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_ai_runs_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_ai_runs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "actorType", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "idempotencyKey", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": "\"idempotencyKey\" IS NOT NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_ai_runs_idempotency_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_ai_runs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "locale", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "field", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_ai_translation_sources_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_ai_translation_sources" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "field", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "language", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_documents_doc_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_content_documents" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_documents_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_documents" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedBy", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_documents_updated_by_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_documents" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "language", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_drafts_key_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_content_drafts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_drafts_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_drafts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedBy", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_drafts_updated_by_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_drafts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "field", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "language", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_field_locks_key_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_content_field_locks" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "expiresAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_field_locks_expires_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_field_locks" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_field_locks_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_field_locks" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "revisionId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "fileId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_file_refs_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_content_file_refs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "fileId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_file_refs_file_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_file_refs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "version", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": "\"languageId\" IS NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_revisions_item_version_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_content_revisions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "version", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": "\"languageId\" IS NOT NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_revisions_translation_version_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_content_revisions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "version", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_revisions_language_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_revisions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "pluginId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_revisions_plugin_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_revisions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "actorUserId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_revisions_actor_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_revisions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "action", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": "status = 'pending'", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_schedules_active_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_content_schedules" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "scheduledFor", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_schedules_due_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_schedules" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_schedules_item_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_schedules" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "pluginId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_schedules_plugin_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_schedules" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdBy", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_schedules_created_by_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_schedules" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": "\"languageId\" IS NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_slug_history_shared_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": "\"languageId\" IS NOT NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_slug_history_locale_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_slug_history_item_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "pluginId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_slug_history_plugin_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "lastRun", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_cron_last_run_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_cron" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_admin_dashboard_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_admin_dashboard" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_files_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_files" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_files_created_at_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_files" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "fileId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "languageCode", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_files_alt_file_language_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_files_alt" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "fileId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "fileFingerprint", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_files_alt_analysis_file_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_files_alt_analysis" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": "status <> 'completed'", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_files_alt_state_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_files_alt_state" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "name", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_languages_name_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_languages" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageCode", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_languages_words_lang_code_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_languages_words" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "tableName", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "pluginCode", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "variable", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_languages_words_lookup_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_languages_words" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_logs_created_at_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_logs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_logs_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_logs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "roleId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_moderators_permissions_role_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_moderators_permissions_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_moderators_permissions_updated_at_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_navigation_position_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_navigation" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "location", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_navigation_location_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_navigation" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "parentId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_navigation_parent_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_navigation" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "availableAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": "status = 'pending'", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_deliveries_claim_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_deliveries_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_deliveries_user_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "pluginId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "type", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "idempotencyKey", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_events_idempotency_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_events_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_events_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "notificationId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "seq", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_receipts_notification_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_receipts_user_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": "\"emailPending\" = true AND \"emailDeliveryId\" IS NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_receipts_email_pending_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "unreadCount", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_user_state_unread_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_user_state" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "pluginId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "type", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "groupKey", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "groupBucket", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notifications_group_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_notifications" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "lastActivityAt", + "isExpression": false, + "asc": false, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": false, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": "\"archivedAt\" IS NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notifications_inbox_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notifications" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "lastActivityAt", + "isExpression": false, + "asc": false, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": false, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": "\"archivedAt\" IS NULL AND \"readSeq\" < \"activitySeq\"", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notifications_unread_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notifications" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "subjectType", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "subjectId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notifications_subject_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notifications" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "lastActivityAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notifications_last_activity_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notifications" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "latestEventId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notifications_latest_event_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notifications" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "expiresAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_passkey_challenges_expires_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_passkey_challenges_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_passkeys_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "webauthnUserId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_passkeys_webauthn_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "availableAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_queue_status_available_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_queue" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_queue_created_at_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_queue" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "pluginId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "dedupeKey", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": "\"dedupeKey\" IS NOT NULL AND status IN ('pending', 'processing')", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_queue_dedupe_active_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_queue" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_roles_updated_at_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_roles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "search_vector", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "gin", + "concurrently": false, + "name": "core_search_index_search_vector_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_search_index" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_search_index_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_search_index" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "authorIds", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "gin", + "concurrently": false, + "name": "core_search_index_author_ids_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_search_index" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemType", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_search_index_item_type_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_search_index" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageCode", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_search_index_language_code_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_search_index" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "isPublic", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_search_index_is_public_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_search_index" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_sessions_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_sessions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "deviceId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_sessions_device_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_sessions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "ipAddress", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_sessions_known_devices_ip_address_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_sessions_known_devices" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "avatarId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_avatar_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "coverId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_cover_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_created_at_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "roleId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_secondary_roles_role_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_secondary_roles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_sso_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_sso" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "providerId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_sso_operations_user_provider_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "expiresAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_sso_operations_expires_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_categories_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_categories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_categories_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_categories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_categories_translations_language_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_status_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "coverImage", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_cover_image_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_status_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "hiddenBy", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_hidden_by_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_author_id_position_key", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "relatedItemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_author_id_related_item_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_category_id_position_key", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "relatedItemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_category_id_related_item_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_translations_language_id_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "friendlyUrl", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_translations_language_id_friendly_url_key", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "syndicationPriority", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_syndication_priority_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_status_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_categories_position_key", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles_categories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "relatedItemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_categories_related_item_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles_categories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_faq_position_key", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles_faq" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_related_articles_position_key", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles_related_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "relatedItemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_related_articles_related_item_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles_related_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_tags_position_key", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles_tags" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "relatedItemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_tags_related_item_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles_tags" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_translations_language_id_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_translations_language_id_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_status_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "code", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_code_key", + "entityType": "indexes", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "author", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_author_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "animation", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_animation_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "category", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_category_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_status_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_gallery_position_key", + "entityType": "indexes", + "schema": "public", + "table": "example_articles_gallery" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "relatedItemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_gallery_related_item_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_articles_gallery" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_categories_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_categories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_categories_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_categories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "primaryTag", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_events_primary_tag_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "category", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_events_category_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_events_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_events_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_events_status_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "hiddenBy", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_events_hidden_by_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_events_gallery_position_key", + "entityType": "indexes", + "schema": "public", + "table": "example_events_gallery" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "relatedItemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_events_gallery_related_item_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_events_gallery" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_events_related_events_position_key", + "entityType": "indexes", + "schema": "public", + "table": "example_events_related_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "relatedItemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_events_related_events_related_item_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_events_related_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_events_sessions_position_key", + "entityType": "indexes", + "schema": "public", + "table": "example_events_sessions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_events_tags_position_key", + "entityType": "indexes", + "schema": "public", + "table": "example_events_tags" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "relatedItemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_events_tags_related_item_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_events_tags" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_events_translations_language_id_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_events_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_events_translations_language_id_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "example_events_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_localized_articles_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_localized_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_localized_articles_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_localized_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_localized_articles_status_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_localized_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_localized_articles_translations_language_id_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_localized_articles_translations_language_id_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_pages_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "example_pages" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_pages_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_pages" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_pages_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_pages" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_pages_status_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_pages" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "category", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_tags_category_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_tags" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_tags_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_tags" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_tags_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_tags" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_tags_status_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_tags" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "hiddenBy", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_tags_hidden_by_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_tags" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_tags_related_tags_position_key", + "entityType": "indexes", + "schema": "public", + "table": "example_tags_related_tags" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "relatedItemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_tags_related_tags_related_item_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_tags_related_tags" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_tags_translations_language_id_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_tags_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_tags_translations_language_id_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "example_tags_translations" + }, + { + "nameExplicit": false, + "columns": [ + "roleId" + ], + "schemaTo": "public", + "tableTo": "core_roles", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_admin_permissions_roleId_core_roles_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_admin_permissions_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_admin_sessions_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "nameExplicit": false, + "columns": [ + "deviceId" + ], + "schemaTo": "public", + "tableTo": "core_sessions_known_devices", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_admin_sessions_deviceId_core_sessions_known_devices_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "nameExplicit": false, + "columns": [ + "runId" + ], + "schemaTo": "public", + "tableTo": "core_ai_runs", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_ai_calls_runId_core_ai_runs_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_ai_calls" + }, + { + "nameExplicit": false, + "columns": [ + "callId" + ], + "schemaTo": "public", + "tableTo": "core_ai_calls", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_ai_cost_adjustments_callId_core_ai_calls_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "nameExplicit": false, + "columns": [ + "runId" + ], + "schemaTo": "public", + "tableTo": "core_ai_runs", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_ai_cost_adjustments_runId_core_ai_runs_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "nameExplicit": false, + "columns": [ + "runId" + ], + "schemaTo": "public", + "tableTo": "core_ai_runs", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_ai_reservations_runId_core_ai_runs_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_ai_reservations" + }, + { + "nameExplicit": false, + "columns": [ + "budgetPeriodId" + ], + "schemaTo": "public", + "tableTo": "core_ai_budget_periods", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_ai_reservations_FClYpPXZwvsm_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_ai_reservations" + }, + { + "nameExplicit": false, + "columns": [ + "roleId" + ], + "schemaTo": "public", + "tableTo": "core_roles", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_ai_role_permissions_roleId_core_roles_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_ai_role_permissions" + }, + { + "nameExplicit": false, + "columns": [ + "roleId" + ], + "schemaTo": "public", + "tableTo": "core_roles", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_ai_role_policies_roleId_core_roles_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_ai_role_policies" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_ai_runs_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_ai_runs" + }, + { + "nameExplicit": false, + "columns": [ + "updatedById" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_ai_translation_sources_updatedById_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_ai_translation_sources" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_ai_user_overrides_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_ai_user_overrides" + }, + { + "nameExplicit": false, + "columns": [ + "updatedBy" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "core_content_documents_updatedBy_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_content_documents" + }, + { + "nameExplicit": false, + "columns": [ + "updatedBy" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "core_content_drafts_updatedBy_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_content_drafts" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "core_content_field_locks_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_content_field_locks" + }, + { + "nameExplicit": false, + "columns": [ + "revisionId" + ], + "schemaTo": "public", + "tableTo": "core_content_revisions", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "core_content_file_refs_5get6SfhBPHr_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_content_file_refs" + }, + { + "nameExplicit": false, + "columns": [ + "fileId" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "core_content_file_refs_fileId_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_content_file_refs" + }, + { + "nameExplicit": false, + "columns": [ + "actorUserId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "core_content_revisions_actorUserId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_content_revisions" + }, + { + "nameExplicit": false, + "columns": [ + "createdBy" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "core_content_schedules_createdBy_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_content_schedules" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_admin_dashboard_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_admin_dashboard" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_files_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_files" + }, + { + "nameExplicit": false, + "columns": [ + "fileId" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_files_alt_fileId_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_files_alt" + }, + { + "nameExplicit": false, + "columns": [ + "languageCode" + ], + "schemaTo": "public", + "tableTo": "core_languages", + "columnsTo": [ + "code" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_files_alt_languageCode_core_languages_code_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_files_alt" + }, + { + "nameExplicit": false, + "columns": [ + "runId" + ], + "schemaTo": "public", + "tableTo": "core_ai_runs", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_files_alt_runId_core_ai_runs_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_files_alt" + }, + { + "nameExplicit": false, + "columns": [ + "updatedById" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_files_alt_updatedById_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_files_alt" + }, + { + "nameExplicit": false, + "columns": [ + "fileId" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_files_alt_analysis_fileId_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_files_alt_analysis" + }, + { + "nameExplicit": false, + "columns": [ + "runId" + ], + "schemaTo": "public", + "tableTo": "core_ai_runs", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_files_alt_analysis_runId_core_ai_runs_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_files_alt_analysis" + }, + { + "nameExplicit": false, + "columns": [ + "fileId" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_files_alt_state_fileId_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_files_alt_state" + }, + { + "nameExplicit": false, + "columns": [ + "languageCode" + ], + "schemaTo": "public", + "tableTo": "core_languages", + "columnsTo": [ + "code" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_languages_words_languageCode_core_languages_code_fk", + "entityType": "fks", + "schema": "public", + "table": "core_languages_words" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "core_logs_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_logs" + }, + { + "nameExplicit": false, + "columns": [ + "roleId" + ], + "schemaTo": "public", + "tableTo": "core_roles", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_moderators_permissions_roleId_core_roles_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_moderators_permissions_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "nameExplicit": false, + "columns": [ + "parentId" + ], + "schemaTo": "public", + "tableTo": "core_navigation", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_navigation_parentId_core_navigation_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_navigation" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_notification_deliveries_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "nameExplicit": false, + "columns": [ + "eventId" + ], + "schemaTo": "public", + "tableTo": "core_notification_events", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_notification_deliveries_vcf37BytIa0M_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "nameExplicit": false, + "columns": [ + "actorId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_notification_events_actorId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_notification_events" + }, + { + "nameExplicit": false, + "columns": [ + "eventId" + ], + "schemaTo": "public", + "tableTo": "core_notification_events", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_notification_receipts_UQ7ZEs0I6ijx_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_notification_receipts_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "nameExplicit": false, + "columns": [ + "notificationId" + ], + "schemaTo": "public", + "tableTo": "core_notifications", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_notification_receipts_Vxye2Cs6MgcL_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_notification_user_state_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_notification_user_state" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_notifications_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_notifications" + }, + { + "nameExplicit": false, + "columns": [ + "latestEventId" + ], + "schemaTo": "public", + "tableTo": "core_notification_events", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "RESTRICT", + "name": "core_notifications_I8QoGbTal5Wt_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_notifications" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_users_passkey_challenges_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_users_passkeys_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_sessions_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_sessions" + }, + { + "nameExplicit": false, + "columns": [ + "deviceId" + ], + "schemaTo": "public", + "tableTo": "core_sessions_known_devices", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_sessions_deviceId_core_sessions_known_devices_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_sessions" + }, + { + "nameExplicit": false, + "columns": [ + "roleId" + ], + "schemaTo": "public", + "tableTo": "core_roles", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "core_users_roleId_core_roles_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_users" + }, + { + "nameExplicit": false, + "columns": [ + "language" + ], + "schemaTo": "public", + "tableTo": "core_languages", + "columnsTo": [ + "code" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET DEFAULT", + "name": "core_users_language_core_languages_code_fk", + "entityType": "fks", + "schema": "public", + "table": "core_users" + }, + { + "nameExplicit": false, + "columns": [ + "avatarId" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_users_avatarId_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_users" + }, + { + "nameExplicit": false, + "columns": [ + "coverId" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_users_coverId_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_users" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_users_confirm_emails_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_users_confirm_emails" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_users_forgot_password_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_users_forgot_password" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_users_secondary_roles_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_users_secondary_roles" + }, + { + "nameExplicit": false, + "columns": [ + "roleId" + ], + "schemaTo": "public", + "tableTo": "core_roles", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_users_secondary_roles_roleId_core_roles_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_users_secondary_roles" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_users_sso_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_users_sso" + }, + { + "nameExplicit": false, + "columns": [ + "avatarFileId" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_users_sso_avatarFileId_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_users_sso" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_users_sso_operations_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_users_sso_profile_sources_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_users_sso_profile_sources" + }, + { + "nameExplicit": true, + "columns": [ + "userId", + "providerId" + ], + "schemaTo": "public", + "tableTo": "core_users_sso", + "columnsTo": [ + "userId", + "providerId" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_users_sso_profile_sources_connection_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_users_sso_profile_sources" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "blog_categories", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "blog_categories_translations_itemId_blog_categories_id_fk", + "entityType": "fks", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "nameExplicit": false, + "columns": [ + "languageId" + ], + "schemaTo": "public", + "tableTo": "core_languages", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "blog_categories_translations_languageId_core_languages_id_fk", + "entityType": "fks", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "nameExplicit": false, + "columns": [ + "hiddenBy" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "blog_posts_hiddenBy_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "blog_posts" + }, + { + "nameExplicit": false, + "columns": [ + "coverImage" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "blog_posts_coverImage_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "blog_posts" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "blog_posts", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "blog_posts_author_id_itemId_blog_posts_id_fk", + "entityType": "fks", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "nameExplicit": false, + "columns": [ + "relatedItemId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "blog_posts_author_id_relatedItemId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "blog_posts", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "blog_posts_category_id_itemId_blog_posts_id_fk", + "entityType": "fks", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "nameExplicit": false, + "columns": [ + "relatedItemId" + ], + "schemaTo": "public", + "tableTo": "blog_categories", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "blog_posts_category_id_relatedItemId_blog_categories_id_fk", + "entityType": "fks", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "blog_posts", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "blog_posts_translations_itemId_blog_posts_id_fk", + "entityType": "fks", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "nameExplicit": false, + "columns": [ + "languageId" + ], + "schemaTo": "public", + "tableTo": "core_languages", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "blog_posts_translations_languageId_core_languages_id_fk", + "entityType": "fks", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "example_advanced_articles", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_advanced_articles_categories_itemId_example_advanced_ar", + "entityType": "fks", + "schema": "public", + "table": "example_advanced_articles_categories" + }, + { + "nameExplicit": false, + "columns": [ + "relatedItemId" + ], + "schemaTo": "public", + "tableTo": "example_categories", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "example_advanced_articles_categories_relatedItemId_example_cate", + "entityType": "fks", + "schema": "public", + "table": "example_advanced_articles_categories" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "example_advanced_articles", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_advanced_articles_faq_itemId_example_advanced_articles_", + "entityType": "fks", + "schema": "public", + "table": "example_advanced_articles_faq" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "example_advanced_articles", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_advanced_articles_related_articles_itemId_example_advan", + "entityType": "fks", + "schema": "public", + "table": "example_advanced_articles_related_articles" + }, + { + "nameExplicit": false, + "columns": [ + "relatedItemId" + ], + "schemaTo": "public", + "tableTo": "example_advanced_articles", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_advanced_articles_related_articles_relatedItemId_exampl", + "entityType": "fks", + "schema": "public", + "table": "example_advanced_articles_related_articles" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "example_advanced_articles", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_advanced_articles_tags_ATchHLRtvoCM_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_advanced_articles_tags" + }, + { + "nameExplicit": false, + "columns": [ + "relatedItemId" + ], + "schemaTo": "public", + "tableTo": "example_tags", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "example_advanced_articles_tags_dEsWdkDmu7nW_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_advanced_articles_tags" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "example_advanced_articles", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_advanced_articles_translations_itemId_example_advanced_", + "entityType": "fks", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "nameExplicit": false, + "columns": [ + "languageId" + ], + "schemaTo": "public", + "tableTo": "core_languages", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "example_advanced_articles_translations_languageId_core_language", + "entityType": "fks", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "nameExplicit": false, + "columns": [ + "author" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "example_articles_author_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": false, + "columns": [ + "animation" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "example_articles_animation_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": false, + "columns": [ + "category" + ], + "schemaTo": "public", + "tableTo": "example_categories", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "example_articles_category_example_categories_id_fk", + "entityType": "fks", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "example_articles", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_articles_gallery_itemId_example_articles_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_articles_gallery" + }, + { + "nameExplicit": false, + "columns": [ + "relatedItemId" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "example_articles_gallery_relatedItemId_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_articles_gallery" + }, + { + "nameExplicit": false, + "columns": [ + "hiddenBy" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "example_events_hiddenBy_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_events" + }, + { + "nameExplicit": false, + "columns": [ + "primaryTag" + ], + "schemaTo": "public", + "tableTo": "example_tags", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "example_events_primaryTag_example_tags_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_events" + }, + { + "nameExplicit": false, + "columns": [ + "category" + ], + "schemaTo": "public", + "tableTo": "example_categories", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "example_events_category_example_categories_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_events" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "example_events", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_events_gallery_itemId_example_events_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_events_gallery" + }, + { + "nameExplicit": false, + "columns": [ + "relatedItemId" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "example_events_gallery_relatedItemId_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_events_gallery" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "example_events", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_events_related_events_itemId_example_events_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_events_related_events" + }, + { + "nameExplicit": false, + "columns": [ + "relatedItemId" + ], + "schemaTo": "public", + "tableTo": "example_events", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_events_related_events_oaRvqnKyAS7L_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_events_related_events" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "example_events", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_events_sessions_itemId_example_events_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_events_sessions" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "example_events", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_events_tags_itemId_example_events_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_events_tags" + }, + { + "nameExplicit": false, + "columns": [ + "relatedItemId" + ], + "schemaTo": "public", + "tableTo": "example_tags", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "example_events_tags_relatedItemId_example_tags_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_events_tags" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "example_events", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_events_translations_itemId_example_events_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_events_translations" + }, + { + "nameExplicit": false, + "columns": [ + "languageId" + ], + "schemaTo": "public", + "tableTo": "core_languages", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "example_events_translations_languageId_core_languages_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_events_translations" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "example_localized_articles", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_localized_articles_translations_itemId_example_localize", + "entityType": "fks", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "nameExplicit": false, + "columns": [ + "languageId" + ], + "schemaTo": "public", + "tableTo": "core_languages", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "example_localized_articles_translations_languageId_core_languag", + "entityType": "fks", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "nameExplicit": false, + "columns": [ + "hiddenBy" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "example_tags_hiddenBy_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_tags" + }, + { + "nameExplicit": false, + "columns": [ + "category" + ], + "schemaTo": "public", + "tableTo": "example_categories", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "example_tags_category_example_categories_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_tags" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "example_tags", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_tags_related_tags_itemId_example_tags_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_tags_related_tags" + }, + { + "nameExplicit": false, + "columns": [ + "relatedItemId" + ], + "schemaTo": "public", + "tableTo": "example_tags", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_tags_related_tags_relatedItemId_example_tags_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_tags_related_tags" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "example_tags", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_tags_translations_itemId_example_tags_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_tags_translations" + }, + { + "nameExplicit": false, + "columns": [ + "languageId" + ], + "schemaTo": "public", + "tableTo": "core_languages", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "example_tags_translations_languageId_core_languages_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_tags_translations" + }, + { + "columns": [ + "roleId", + "permission" + ], + "nameExplicit": false, + "name": "core_ai_role_permissions_pkey", + "entityType": "pks", + "schema": "public", + "table": "core_ai_role_permissions" + }, + { + "columns": [ + "eventId", + "userId" + ], + "nameExplicit": false, + "name": "core_notification_receipts_pkey", + "entityType": "pks", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "columns": [ + "userId", + "roleId" + ], + "nameExplicit": false, + "name": "core_users_secondary_roles_userId_roleId_pk", + "entityType": "pks", + "schema": "public", + "table": "core_users_secondary_roles" + }, + { + "columns": [ + "userId", + "field" + ], + "nameExplicit": false, + "name": "core_users_sso_profile_sources_pkey", + "entityType": "pks", + "schema": "public", + "table": "core_users_sso_profile_sources" + }, + { + "columns": [ + "itemId", + "languageId" + ], + "nameExplicit": true, + "name": "blog_categories_translations_item_id_language_id_pk", + "entityType": "pks", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "columns": [ + "itemId", + "relatedItemId" + ], + "nameExplicit": true, + "name": "blog_posts_author_id_pk", + "entityType": "pks", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "columns": [ + "itemId", + "relatedItemId" + ], + "nameExplicit": true, + "name": "blog_posts_category_id_pk", + "entityType": "pks", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "columns": [ + "itemId", + "languageId" + ], + "nameExplicit": true, + "name": "blog_posts_translations_item_id_language_id_pk", + "entityType": "pks", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "columns": [ + "itemId", + "relatedItemId" + ], + "nameExplicit": true, + "name": "example_advanced_articles_categories_pk", + "entityType": "pks", + "schema": "public", + "table": "example_advanced_articles_categories" + }, + { + "columns": [ + "itemId", + "relatedItemId" + ], + "nameExplicit": true, + "name": "example_advanced_articles_related_articles_pk", + "entityType": "pks", + "schema": "public", + "table": "example_advanced_articles_related_articles" + }, + { + "columns": [ + "itemId", + "relatedItemId" + ], + "nameExplicit": true, + "name": "example_advanced_articles_tags_pk", + "entityType": "pks", + "schema": "public", + "table": "example_advanced_articles_tags" + }, + { + "columns": [ + "itemId", + "languageId" + ], + "nameExplicit": true, + "name": "example_advanced_articles_translations_item_id_language_id_pk", + "entityType": "pks", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "columns": [ + "itemId", + "relatedItemId" + ], + "nameExplicit": true, + "name": "example_articles_gallery_pk", + "entityType": "pks", + "schema": "public", + "table": "example_articles_gallery" + }, + { + "columns": [ + "itemId", + "relatedItemId" + ], + "nameExplicit": true, + "name": "example_events_gallery_pk", + "entityType": "pks", + "schema": "public", + "table": "example_events_gallery" + }, + { + "columns": [ + "itemId", + "relatedItemId" + ], + "nameExplicit": true, + "name": "example_events_related_events_pk", + "entityType": "pks", + "schema": "public", + "table": "example_events_related_events" + }, + { + "columns": [ + "itemId", + "relatedItemId" + ], + "nameExplicit": true, + "name": "example_events_tags_pk", + "entityType": "pks", + "schema": "public", + "table": "example_events_tags" + }, + { + "columns": [ + "itemId", + "languageId" + ], + "nameExplicit": true, + "name": "example_events_translations_item_id_language_id_pk", + "entityType": "pks", + "schema": "public", + "table": "example_events_translations" + }, + { + "columns": [ + "itemId", + "languageId" + ], + "nameExplicit": true, + "name": "example_localized_articles_translations_item_id_language_id_pk", + "entityType": "pks", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "columns": [ + "itemId", + "relatedItemId" + ], + "nameExplicit": true, + "name": "example_tags_related_tags_pk", + "entityType": "pks", + "schema": "public", + "table": "example_tags_related_tags" + }, + { + "columns": [ + "itemId", + "languageId" + ], + "nameExplicit": true, + "name": "example_tags_translations_item_id_language_id_pk", + "entityType": "pks", + "schema": "public", + "table": "example_tags_translations" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_admin_permissions_pkey", + "schema": "public", + "table": "core_admin_permissions", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_admin_sessions_pkey", + "schema": "public", + "table": "core_admin_sessions", + "entityType": "pks" + }, + { + "columns": [ + "actionKey" + ], + "nameExplicit": false, + "name": "core_ai_action_settings_pkey", + "schema": "public", + "table": "core_ai_action_settings", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_ai_budget_periods_pkey", + "schema": "public", + "table": "core_ai_budget_periods", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_ai_calls_pkey", + "schema": "public", + "table": "core_ai_calls", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_ai_cost_adjustments_pkey", + "schema": "public", + "table": "core_ai_cost_adjustments", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_ai_reservations_pkey", + "schema": "public", + "table": "core_ai_reservations", + "entityType": "pks" + }, + { + "columns": [ + "roleId" + ], + "nameExplicit": false, + "name": "core_ai_role_policies_pkey", + "schema": "public", + "table": "core_ai_role_policies", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_ai_runs_pkey", + "schema": "public", + "table": "core_ai_runs", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_ai_settings_pkey", + "schema": "public", + "table": "core_ai_settings", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_ai_translation_sources_pkey", + "schema": "public", + "table": "core_ai_translation_sources", + "entityType": "pks" + }, + { + "columns": [ + "userId" + ], + "nameExplicit": false, + "name": "core_ai_user_overrides_pkey", + "schema": "public", + "table": "core_ai_user_overrides", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_content_documents_pkey", + "schema": "public", + "table": "core_content_documents", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_content_drafts_pkey", + "schema": "public", + "table": "core_content_drafts", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_content_field_locks_pkey", + "schema": "public", + "table": "core_content_field_locks", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_content_file_refs_pkey", + "schema": "public", + "table": "core_content_file_refs", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_content_revisions_pkey", + "schema": "public", + "table": "core_content_revisions", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_content_schedules_pkey", + "schema": "public", + "table": "core_content_schedules", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_content_slug_history_pkey", + "schema": "public", + "table": "core_content_slug_history", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_cron_pkey", + "schema": "public", + "table": "core_cron", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_admin_dashboard_pkey", + "schema": "public", + "table": "core_admin_dashboard", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_files_pkey", + "schema": "public", + "table": "core_files", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_files_alt_pkey", + "schema": "public", + "table": "core_files_alt", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_files_alt_analysis_pkey", + "schema": "public", + "table": "core_files_alt_analysis", + "entityType": "pks" + }, + { + "columns": [ + "fileId" + ], + "nameExplicit": false, + "name": "core_files_alt_state_pkey", + "schema": "public", + "table": "core_files_alt_state", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_languages_pkey", + "schema": "public", + "table": "core_languages", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_languages_words_pkey", + "schema": "public", + "table": "core_languages_words", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_logs_pkey", + "schema": "public", + "table": "core_logs", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_moderators_permissions_pkey", + "schema": "public", + "table": "core_moderators_permissions", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_navigation_pkey", + "schema": "public", + "table": "core_navigation", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_notification_deliveries_pkey", + "schema": "public", + "table": "core_notification_deliveries", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_notification_events_pkey", + "schema": "public", + "table": "core_notification_events", + "entityType": "pks" + }, + { + "columns": [ + "key" + ], + "nameExplicit": false, + "name": "core_notification_settings_pkey", + "schema": "public", + "table": "core_notification_settings", + "entityType": "pks" + }, + { + "columns": [ + "userId" + ], + "nameExplicit": false, + "name": "core_notification_user_state_pkey", + "schema": "public", + "table": "core_notification_user_state", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_notifications_pkey", + "schema": "public", + "table": "core_notifications", + "entityType": "pks" + }, + { + "columns": [ + "pageId" + ], + "nameExplicit": false, + "name": "core_page_layouts_pkey", + "schema": "public", + "table": "core_page_layouts", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_users_passkey_challenges_pkey", + "schema": "public", + "table": "core_users_passkey_challenges", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_users_passkeys_pkey", + "schema": "public", + "table": "core_users_passkeys", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_queue_pkey", + "schema": "public", + "table": "core_queue", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_roles_pkey", + "schema": "public", + "table": "core_roles", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_search_index_pkey", + "schema": "public", + "table": "core_search_index", + "entityType": "pks" + }, + { + "columns": [ + "name" + ], + "nameExplicit": false, + "name": "core_secrets_pkey", + "schema": "public", + "table": "core_secrets", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_sessions_pkey", + "schema": "public", + "table": "core_sessions", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_sessions_known_devices_pkey", + "schema": "public", + "table": "core_sessions_known_devices", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_users_pkey", + "schema": "public", + "table": "core_users", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_users_confirm_emails_pkey", + "schema": "public", + "table": "core_users_confirm_emails", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_users_forgot_password_pkey", + "schema": "public", + "table": "core_users_forgot_password", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_users_sso_operations_pkey", + "schema": "public", + "table": "core_users_sso_operations", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "blog_categories_pkey", + "schema": "public", + "table": "blog_categories", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "blog_posts_pkey", + "schema": "public", + "table": "blog_posts", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "example_advanced_articles_pkey", + "schema": "public", + "table": "example_advanced_articles", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "example_advanced_articles_faq_pkey", + "schema": "public", + "table": "example_advanced_articles_faq", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "example_articles_pkey", + "schema": "public", + "table": "example_articles", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "example_categories_pkey", + "schema": "public", + "table": "example_categories", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "example_events_pkey", + "schema": "public", + "table": "example_events", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "example_events_sessions_pkey", + "schema": "public", + "table": "example_events_sessions", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "example_localized_articles_pkey", + "schema": "public", + "table": "example_localized_articles", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "example_pages_pkey", + "schema": "public", + "table": "example_pages", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "example_tags_pkey", + "schema": "public", + "table": "example_tags", + "entityType": "pks" + }, + { + "nameExplicit": true, + "columns": [ + "itemType", + "itemId", + "languageCode" + ], + "nullsNotDistinct": false, + "name": "core_search_index_item_unique", + "entityType": "uniques", + "schema": "public", + "table": "core_search_index" + }, + { + "nameExplicit": true, + "columns": [ + "providerId", + "providerAccountId" + ], + "nullsNotDistinct": false, + "name": "core_users_sso_provider_account_key", + "entityType": "uniques", + "schema": "public", + "table": "core_users_sso" + }, + { + "nameExplicit": true, + "columns": [ + "userId", + "providerId" + ], + "nullsNotDistinct": false, + "name": "core_users_sso_user_provider_key", + "entityType": "uniques", + "schema": "public", + "table": "core_users_sso" + }, + { + "nameExplicit": false, + "columns": [ + "token" + ], + "nullsNotDistinct": false, + "name": "core_admin_sessions_token_unique", + "schema": "public", + "table": "core_admin_sessions", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "nullsNotDistinct": false, + "name": "core_admin_dashboard_userId_unique", + "schema": "public", + "table": "core_admin_dashboard", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "key" + ], + "nullsNotDistinct": false, + "name": "core_files_key_unique", + "schema": "public", + "table": "core_files", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "code" + ], + "nullsNotDistinct": false, + "name": "core_languages_code_unique", + "schema": "public", + "table": "core_languages", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "idempotencyKey" + ], + "nullsNotDistinct": false, + "name": "core_notification_deliveries_idempotencyKey_key", + "schema": "public", + "table": "core_notification_deliveries", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "tokenHash" + ], + "nullsNotDistinct": false, + "name": "core_users_passkey_challenges_tokenHash_key", + "schema": "public", + "table": "core_users_passkey_challenges", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "credentialId" + ], + "nullsNotDistinct": false, + "name": "core_users_passkeys_credentialId_key", + "schema": "public", + "table": "core_users_passkeys", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "token" + ], + "nullsNotDistinct": false, + "name": "core_sessions_token_unique", + "schema": "public", + "table": "core_sessions", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "publicId" + ], + "nullsNotDistinct": false, + "name": "core_sessions_known_devices_publicId_unique", + "schema": "public", + "table": "core_sessions_known_devices", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "nameCode" + ], + "nullsNotDistinct": false, + "name": "core_users_nameCode_unique", + "schema": "public", + "table": "core_users", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "name" + ], + "nullsNotDistinct": false, + "name": "core_users_name_unique", + "schema": "public", + "table": "core_users", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "email" + ], + "nullsNotDistinct": false, + "name": "core_users_email_unique", + "schema": "public", + "table": "core_users", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "token" + ], + "nullsNotDistinct": false, + "name": "core_users_confirm_emails_token_unique", + "schema": "public", + "table": "core_users_confirm_emails", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "nullsNotDistinct": false, + "name": "core_users_forgot_password_userId_unique", + "schema": "public", + "table": "core_users_forgot_password", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "token" + ], + "nullsNotDistinct": false, + "name": "core_users_forgot_password_token_unique", + "schema": "public", + "table": "core_users_forgot_password", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "tokenHash" + ], + "nullsNotDistinct": false, + "name": "core_users_sso_operations_tokenHash_key", + "schema": "public", + "table": "core_users_sso_operations", + "entityType": "uniques" + }, + { + "value": "\"intent\" IN ('link', 'import', 'sync', 'preview')", + "name": "core_users_sso_operations_intent_check", + "entityType": "checks", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "value": "\"field\" IN ('avatar', 'firstName', 'lastName')", + "name": "core_users_sso_profile_sources_field_check", + "entityType": "checks", + "schema": "public", + "table": "core_users_sso_profile_sources" + } + ], + "renames": [] +} \ No newline at end of file From 1ebbbdece1d548bd93fa173011f59dc080d85e58 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 09:44:21 +0000 Subject: [PATCH 12/17] =?UTF-8?q?test(content):=20=F0=9F=94=A5=20drop=20te?= =?UTF-8?q?sts=20the=20AGENTS.md=20test=20rules=20rule=20out?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removes Drizzle column-type assertions, re-assertions of the id parser through wrappers, static constant and option-value checks, duplicated render tests whose logic has a pure-function test, and thin wrappers over the shared confirm dialog; collapses near-identical cases into it.each tables and moves the filter selection tests next to their function. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VpG3D33ZqXEYjN2N6eNcMK --- .../vitnode/src/api/models/search.test.ts | 14 -- .../src/components/table/filters.test.ts | 18 ++ .../vitnode/src/content/admin/route.test.ts | 48 ++---- packages/vitnode/src/content/cache.test.ts | 32 ++-- .../vitnode/src/content/id-strategy.test-d.ts | 52 +----- packages/vitnode/src/content/ids.test.ts | 62 +------ .../src/content/server/preview-token.test.ts | 7 +- .../vitnode/src/content/server/query.test.ts | 11 -- .../src/content/server/search-sync.test.ts | 14 -- .../src/content/server/visibility.test-d.ts | 3 - .../admin/content/row-actions.test.tsx | 46 ----- .../content/actions/row-actions-model.test.ts | 10 -- .../content/actions/row-dialogs.test.tsx | 157 ------------------ .../views/content/form/mutations-api.test.ts | 22 --- .../views/content/form/visibility.test.tsx | 36 ++-- .../admin/views/content/table/cells.test.tsx | 27 +-- .../views/content/table/list-filters.test.ts | 49 ++---- .../views/content/table/list-query.test.ts | 38 ----- 18 files changed, 86 insertions(+), 560 deletions(-) create mode 100644 packages/vitnode/src/components/table/filters.test.ts delete mode 100644 packages/vitnode/src/views/admin/views/content/actions/row-dialogs.test.tsx diff --git a/packages/vitnode/src/api/models/search.test.ts b/packages/vitnode/src/api/models/search.test.ts index a24b52f0ce..1f4f06a0f6 100644 --- a/packages/vitnode/src/api/models/search.test.ts +++ b/packages/vitnode/src/api/models/search.test.ts @@ -514,20 +514,6 @@ describe("item ids under every strategy", () => { ); }); - it("deletes a uuid record by its key", async () => { - const provider = createProvider(); - const { c } = createContext(provider); - - await new SearchModel(c).delete("test.uuid-tag", UUID, "en"); - - expect(provider.delete).toHaveBeenCalledWith( - c, - "test.uuid-tag", - UUID, - "en", - ); - }); - it("reads a key back as the id its content type uses", () => { expect(searchItemIdFromKey(types, "test.searchable", "42")).toBe(42); expect(searchItemIdFromKey(types, "test.bigint-event", BEYOND_SAFE)).toBe( diff --git a/packages/vitnode/src/components/table/filters.test.ts b/packages/vitnode/src/components/table/filters.test.ts new file mode 100644 index 0000000000..268c715f31 --- /dev/null +++ b/packages/vitnode/src/components/table/filters.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vitest"; + +import { nextFilterSelection } from "./filters"; + +describe("nextFilterSelection", () => { + it("replaces the value of a single-value filter, and clears it on a second pick", () => { + expect(nextFilterSelection([], "hidden", true)).toEqual(["hidden"]); + expect(nextFilterSelection(["hidden"], "visible", true)).toEqual([ + "visible", + ]); + expect(nextFilterSelection(["hidden"], "hidden", true)).toEqual([]); + }); + + it("toggles one value of a multi-value filter, keeping the rest", () => { + expect(nextFilterSelection(["a"], "b")).toEqual(["a", "b"]); + expect(nextFilterSelection(["a", "b"], "a")).toEqual(["b"]); + }); +}); diff --git a/packages/vitnode/src/content/admin/route.test.ts b/packages/vitnode/src/content/admin/route.test.ts index e7ac489174..0721ff2abb 100644 --- a/packages/vitnode/src/content/admin/route.test.ts +++ b/packages/vitnode/src/content/admin/route.test.ts @@ -121,41 +121,25 @@ describe("resolveContentAdminRoute", () => { const bigintType = strategyOf("bigint"); const UUID = "0198f6f7-d4a2-7ce1-a2ee-4f5f1f2f3a4b"; - it("reads a uuid record's edit URL as its canonical string", () => { - expect( - resolveContentAdminRoute( - ["test", "uuid", UUID, "edit"], - lookupOf(uuidType), - ), - ).toEqual({ action: "edit", contentTypeId: "test.uuid", itemId: UUID }); - }); - - it("keeps a bigint beyond Number.MAX_SAFE_INTEGER exact", () => { - expect( - resolveContentAdminRoute( - ["test", "bigint", "9007199254740993", "edit"], - lookupOf(bigintType), - ), - ).toEqual({ - action: "edit", - contentTypeId: "test.bigint", - itemId: "9007199254740993", - }); - }); - it.each([ - ["an uppercase uuid", ["test", "uuid", UUID.toUpperCase(), "edit"]], - ["a number for a uuid", ["test", "uuid", "42", "edit"]], - ["a padded bigint", ["test", "bigint", "01", "edit"]], + ["a uuid", uuidType, "uuid", UUID], [ - "a bigint out of range", - ["test", "bigint", "9223372036854775808", "edit"], + "a bigint beyond Number.MAX_SAFE_INTEGER", + bigintType, + "bigint", + "9007199254740993", ], - ])("resolves nothing for %s", (_name, slug) => { - expect( - resolveContentAdminRoute(slug, lookupOf(uuidType, bigintType)), - ).toBeUndefined(); - }); + ])( + "reads %s record's edit URL as its exact string id", + (_name, definition, segment, itemId) => { + expect( + resolveContentAdminRoute( + ["test", segment, itemId, "edit"], + lookupOf(definition), + ), + ).toEqual({ action: "edit", contentTypeId: definition.id, itemId }); + }, + ); }); it("prefers an exact content type path over a create page", () => { diff --git a/packages/vitnode/src/content/cache.test.ts b/packages/vitnode/src/content/cache.test.ts index 98657cf6dd..c1eb0e39f7 100644 --- a/packages/vitnode/src/content/cache.test.ts +++ b/packages/vitnode/src/content/cache.test.ts @@ -108,32 +108,22 @@ describe("isContentPubliclyVisible", () => { ).toBe(false); }); - it("refuses a hidden row whatever its publication says", () => { - expect( - isContentPubliclyVisible({ - hiddenAt: past, - publishedAt: past, - status: "published", - }), - ).toBe(false); - // The JSON form a schedule payload carries. - expect( - isContentPubliclyVisible({ - hiddenAt: past.toISOString(), - publishedAt: past, - status: "published", - }), - ).toBe(false); - }); - - it("judges a row without visibility - or not hidden - exactly as before", () => { + it.each([ + ["refuses a hidden published row", past, false], + [ + "refuses a hidden row as a schedule payload carries it", + past.toISOString(), + false, + ], + ["shows a published row that is not hidden", null, true], + ])("%s", (_name, hiddenAt, visible) => { expect( isContentPubliclyVisible({ - hiddenAt: null, + hiddenAt, publishedAt: past, status: "published", }), - ).toBe(true); + ).toBe(visible); }); it("takes every language down with a hidden base row", () => { diff --git a/packages/vitnode/src/content/id-strategy.test-d.ts b/packages/vitnode/src/content/id-strategy.test-d.ts index 40497f225d..77588a7237 100644 --- a/packages/vitnode/src/content/id-strategy.test-d.ts +++ b/packages/vitnode/src/content/id-strategy.test-d.ts @@ -1,8 +1,7 @@ import { describe, expectTypeOf, it } from "vitest"; -import type { testArticleContentType } from "@/tests/content-fixtures"; - -import { +import type { + testArticleContentType, testBigintEventContentType, testBigintPageContentType, testCategoryContentType, @@ -23,9 +22,6 @@ import type { ContentTranslationRow, } from "./types"; -import { createContentTable } from "./server/table"; -import { createContentTranslationTable } from "./server/translation-table"; - type Tag = typeof testUuidTagContentType; type Event = typeof testBigintEventContentType; type Page = typeof testBigintPageContentType; @@ -149,50 +145,6 @@ describe("events", () => { }); }); -describe("generated tables", () => { - const categories = createContentTable(testCategoryContentType); - const tags = createContentTable(testUuidTagContentType, { - references: { category: () => categories.id }, - }); - const _events = createContentTable(testBigintEventContentType, { - references: { - category: () => categories.id, - primaryTag: () => tags.id, - tags: () => tags.id, - }, - }); - const pages = createContentTable(testBigintPageContentType); - const _pageTranslations = createContentTranslationTable( - testBigintPageContentType, - { table: pages }, - ); - - it("infer their select rows from the same strategies", () => { - expectTypeOf<(typeof tags)["$inferSelect"]["id"]>().toEqualTypeOf(); - expectTypeOf< - (typeof _events)["$inferSelect"]["id"] - >().toEqualTypeOf(); - expectTypeOf< - (typeof _events)["$inferSelect"]["primaryTag"] - >().toEqualTypeOf(); - expectTypeOf<(typeof _events)["$inferSelect"]["category"]>().toEqualTypeOf< - null | number - >(); - expectTypeOf< - (typeof _pageTranslations)["$inferSelect"]["itemId"] - >().toEqualTypeOf(); - }); - - it("generate the id, so an insert may leave it out", () => { - expectTypeOf< - undefined extends (typeof tags)["$inferInsert"]["id"] ? true : false - >().toEqualTypeOf(); - expectTypeOf< - undefined extends (typeof _events)["$inferInsert"]["id"] ? true : false - >().toEqualTypeOf(); - }); -}); - describe("relation targets", () => { it("infer serial for every relation written before strategies existed", () => { type Category = (typeof testArticleContentType)["fields"]["category"]; diff --git a/packages/vitnode/src/content/ids.test.ts b/packages/vitnode/src/content/ids.test.ts index 40f47bd2ad..d95578e1a6 100644 --- a/packages/vitnode/src/content/ids.test.ts +++ b/packages/vitnode/src/content/ids.test.ts @@ -1,21 +1,13 @@ import { describe, expect, it } from "vitest"; -import { - testArticleContentType, - testBigintEventContentType, - testUuidTagContentType, -} from "@/tests/content-fixtures"; +import { testBigintEventContentType } from "@/tests/content-fixtures"; import { ContentEngineError } from "./errors"; import { compareContentIds, CONTENT_BIGINT_MAX, CONTENT_SERIAL_MAX, - contentIdFromKey, - contentIdKey, - contentIdParamSchema, contentIdSchema, - contentIdsOf, contentRelationStrategy, isContentId, parseContentId, @@ -78,25 +70,6 @@ describe("parseContentId", () => { }); }); -describe("storage keys", () => { - it("keep a serial record's key exactly the digits it always had", () => { - expect(contentIdKey(42)).toBe("42"); - expect(contentIdFromKey("serial", "42")).toBe(42); - }); - - it("round-trip every strategy through its key", () => { - expect(contentIdFromKey("bigint", contentIdKey(BEYOND_SAFE))).toBe( - BEYOND_SAFE, - ); - expect(contentIdFromKey("uuid", contentIdKey(UUID))).toBe(UUID); - }); - - it("read nothing out of a key the strategy cannot hold", () => { - expect(contentIdFromKey("serial", UUID)).toBeNull(); - expect(contentIdFromKey("uuid", "42")).toBeNull(); - }); -}); - describe("isContentId", () => { it("is true only for the strategy's own representation", () => { expect(isContentId("serial", 7)).toBe(true); @@ -108,10 +81,6 @@ describe("isContentId", () => { }); describe("requireContentId", () => { - it("returns the canonical id", () => { - expect(requireContentId("bigint", BEYOND_SAFE)).toBe(BEYOND_SAFE); - }); - it("throws rather than handing back a sentinel", () => { expect(() => requireContentId("uuid", 0, "test.tag")).toThrow( ContentEngineError, @@ -119,13 +88,6 @@ describe("requireContentId", () => { }); }); -describe("contentIdsOf", () => { - it("keeps the readable ids, canonicalised, in order", () => { - expect(contentIdsOf("serial", [3, "1", "x", 2.5])).toEqual([3, 1]); - expect(contentIdsOf("uuid", [UUID, "nope"])).toEqual([UUID]); - }); -}); - describe("compareContentIds", () => { it("orders bigints numerically without losing precision", () => { expect(compareContentIds("bigint", "10", "9")).toBeGreaterThan(0); @@ -151,28 +113,6 @@ describe("schemas", () => { ); }); - it("parse a path or query value into the strategy's representation", () => { - expect(contentIdParamSchema("serial").parse("7")).toBe(7); - expect(contentIdParamSchema("bigint").parse(BEYOND_SAFE)).toBe(BEYOND_SAFE); - expect(contentIdParamSchema("uuid").safeParse("7").success).toBe(false); - }); - - it("keep a serial content type's `{id}` the coerced number it always was", () => { - expect(testArticleContentType.schemas.params.parse({ id: "7" })).toEqual({ - id: 7, - }); - }); - - it("refuse a non-canonical `{id}` on a uuid or bigint content type", () => { - const { params: tagParams } = testUuidTagContentType.schemas; - const { params: eventParams } = testBigintEventContentType.schemas; - - expect(tagParams.safeParse({ id: UUID }).success).toBe(true); - expect(tagParams.safeParse({ id: UUID.toUpperCase() }).success).toBe(false); - expect(eventParams.parse({ id: BEYOND_SAFE })).toEqual({ id: BEYOND_SAFE }); - expect(eventParams.safeParse({ id: "01" }).success).toBe(false); - }); - it("type relation values by the target's strategy, a self-relation by the owner's", () => { const { create } = testBigintEventContentType.schemas; diff --git a/packages/vitnode/src/content/server/preview-token.test.ts b/packages/vitnode/src/content/server/preview-token.test.ts index 7725fd9956..49e0640454 100644 --- a/packages/vitnode/src/content/server/preview-token.test.ts +++ b/packages/vitnode/src/content/server/preview-token.test.ts @@ -153,7 +153,8 @@ describe("tokens for every id strategy", () => { const round = ( definition: - typeof testBigintEventContentType | typeof testUuidTagContentType, + | typeof testBigintEventContentType + | typeof testUuidTagContentType, itemId: number | string, ) => verifyContentPreviewToken({ @@ -184,8 +185,4 @@ describe("tokens for every id strategy", () => { expect(round(testUuidTagContentType, 7)).toBeNull(); expect(round(testBigintEventContentType, UUID)).toBeNull(); }); - - it("still verifies a serial token whose id is a number, as every token minted before was", () => { - expect(verify(mint({ itemId: 7 }).token)?.i).toBe(7); - }); }); diff --git a/packages/vitnode/src/content/server/query.test.ts b/packages/vitnode/src/content/server/query.test.ts index e8f1c293cf..faa7f1f93f 100644 --- a/packages/vitnode/src/content/server/query.test.ts +++ b/packages/vitnode/src/content/server/query.test.ts @@ -315,17 +315,6 @@ describe("buildFilterCondition", () => { ); }); - it("composes with the status filter", () => { - const { params, sql } = compile( - visibilityFilter({ status: "published", visibility: "hidden" }), - ); - - expect(sql).toBe( - '(("test_hideable_notes"."status" = $1) and (("test_hideable_notes"."hiddenAt" is not null)))', - ); - expect(params).toEqual(["published"]); - }); - it.each([["secret"], [""], [null], [true]])( "rejects %j before it reaches SQL", value => { diff --git a/packages/vitnode/src/content/server/search-sync.test.ts b/packages/vitnode/src/content/server/search-sync.test.ts index 48081e8530..a3e8801c19 100644 --- a/packages/vitnode/src/content/server/search-sync.test.ts +++ b/packages/vitnode/src/content/server/search-sync.test.ts @@ -561,18 +561,4 @@ describe("hide and unhide", () => { }), ); }); - - it("touches nothing for a repeat", async () => { - const { c, search } = searchOf(); - - const outcome = await syncContentSearch(c, testHideablePostContentType, { - changed: false, - operation: "hide", - row, - }); - - expect(outcome.action).toBe("skip"); - expect(search.index).not.toHaveBeenCalled(); - expect(search.delete).not.toHaveBeenCalled(); - }); }); diff --git a/packages/vitnode/src/content/server/visibility.test-d.ts b/packages/vitnode/src/content/server/visibility.test-d.ts index a268995800..434ece018b 100644 --- a/packages/vitnode/src/content/server/visibility.test-d.ts +++ b/packages/vitnode/src/content/server/visibility.test-d.ts @@ -68,9 +68,6 @@ describe("hide and unhide on the services", () => { expectTypeOf().toEqualTypeOf(); expectTypeOf().toEqualTypeOf(); expectTypeOf().not.toHaveProperty("hiddenAt"); - expectTypeOf< - (typeof hideableNotes.table.$inferSelect)["hiddenAt"] - >().toEqualTypeOf(); }); it("accepts the visibility filter on the admin list only with visibility", () => { diff --git a/packages/vitnode/src/tanstack/admin/content/row-actions.test.tsx b/packages/vitnode/src/tanstack/admin/content/row-actions.test.tsx index b81fccf272..db3dafece2 100644 --- a/packages/vitnode/src/tanstack/admin/content/row-actions.test.tsx +++ b/packages/vitnode/src/tanstack/admin/content/row-actions.test.tsx @@ -140,52 +140,6 @@ afterEach(() => { vi.restoreAllMocks(); }); -describe("ContentRowActions - permission gating", () => { - it("offers Duplicate and Hide to an editor who may create, view and hide", () => { - mount({ permissions: grant("can_view", "can_create", "can_hide") }); - - expect( - screen.getByRole("button", { name: "core.content.actions.duplicate" }), - ).toBeTruthy(); - expect( - screen.getByRole("button", { name: "core.content.actions.hide" }), - ).toBeTruthy(); - }); - - it("offers no Duplicate without can_create", () => { - mount({ permissions: grant("can_view", "can_hide") }); - - expect( - screen.queryByRole("button", { name: "core.content.actions.duplicate" }), - ).toBeNull(); - expect( - screen.getByRole("button", { name: "core.content.actions.hide" }), - ).toBeTruthy(); - }); - - it("offers no Hide without can_hide - can_publish is not enough", () => { - mount({ permissions: grant("can_view", "can_create", "can_publish") }); - - expect( - screen.queryByRole("button", { name: "core.content.actions.hide" }), - ).toBeNull(); - }); - - it("offers Unhide for a hidden row", () => { - mount({ - permissions: grant("can_hide"), - record: row({ hiddenAt: "2026-10-01T09:00:00.000Z" }), - }); - - expect( - screen.getByRole("button", { name: "core.content.actions.unhide" }), - ).toBeTruthy(); - expect( - screen.queryByRole("button", { name: "core.content.actions.hide" }), - ).toBeNull(); - }); -}); - describe("ContentRowActions - hide", () => { it("hides with the row's version after the confirmation, then says so", async () => { const success = vi.spyOn(toast, "success"); diff --git a/packages/vitnode/src/views/admin/views/content/actions/row-actions-model.test.ts b/packages/vitnode/src/views/admin/views/content/actions/row-actions-model.test.ts index d793d85b81..1fc7f7f7a7 100644 --- a/packages/vitnode/src/views/admin/views/content/actions/row-actions-model.test.ts +++ b/packages/vitnode/src/views/admin/views/content/actions/row-actions-model.test.ts @@ -3,7 +3,6 @@ import { describe, expect, it } from "vitest"; import type { ContentRowActionInput } from "./row-actions-model"; import { - CONTENT_EDITORIAL_ACTION_IDS, CONTENT_ROW_ACTION_IDS, CONTENT_ROW_INLINE_ACTION_LIMIT, contentRowActionIds, @@ -183,15 +182,6 @@ describe("duplicate and visibility", () => { contentRowActionIds({ ...nothing, canHide: true, visibility: true }), ).toEqual(["visibility"]); }); - - it("keeps them out of the editorial panels a host registers", () => { - expect(CONTENT_EDITORIAL_ACTION_IDS).toEqual([ - "preview", - "schedule", - "history", - "delivery", - ]); - }); }); describe("contentRowActionsAreInline", () => { diff --git a/packages/vitnode/src/views/admin/views/content/actions/row-dialogs.test.tsx b/packages/vitnode/src/views/admin/views/content/actions/row-dialogs.test.tsx deleted file mode 100644 index 4ad5c24b96..0000000000 --- a/packages/vitnode/src/views/admin/views/content/actions/row-dialogs.test.tsx +++ /dev/null @@ -1,157 +0,0 @@ -import { fireEvent, render, screen, waitFor } from "@testing-library/react"; -import { IntlProvider } from "use-intl"; -import { describe, expect, it, vi } from "vitest"; - -import { ContentDuplicateDialog } from "./duplicate-dialog"; -import { ContentVisibilityDialog } from "./visibility-dialog"; - -const wrapper = ({ children }: { children: React.ReactNode }) => ( - - {children} - -); - -const renderVisibility = (action: "hide" | "unhide", confirmed = true) => { - const onConfirm = vi.fn(async () => Promise.resolve(confirmed)); - - render( - - - , - { wrapper }, - ); - - fireEvent.click(screen.getByRole("button", { name: "open" })); - - return { onConfirm }; -}; - -describe("ContentVisibilityDialog", () => { - it("explains what hiding does before it hides anything", async () => { - const { onConfirm } = renderVisibility("hide"); - - expect(await screen.findByText("core.content.hide.title")).toBeTruthy(); - expect(screen.getByText("core.content.hide.desc")).toBeTruthy(); - expect(onConfirm).not.toHaveBeenCalled(); - - fireEvent.click( - screen.getByRole("button", { name: "core.content.hide.confirm" }), - ); - - await waitFor(() => { - expect(onConfirm).toHaveBeenCalledTimes(1); - }); - await waitFor(() => { - expect(screen.queryByText("core.content.hide.title")).toBeNull(); - }); - }); - - it("asks to unhide a hidden record with its own words", async () => { - const { onConfirm } = renderVisibility("unhide"); - - expect(await screen.findByText("core.content.unhide.desc")).toBeTruthy(); - - fireEvent.click( - screen.getByRole("button", { name: "core.content.unhide.confirm" }), - ); - - await waitFor(() => { - expect(onConfirm).toHaveBeenCalledTimes(1); - }); - }); - - it("stays open when the write failed, so the editor can try again", async () => { - const { onConfirm } = renderVisibility("hide", false); - - fireEvent.click( - await screen.findByRole("button", { name: "core.content.hide.confirm" }), - ); - - await waitFor(() => { - expect(onConfirm).toHaveBeenCalledTimes(1); - }); - expect(screen.getByText("core.content.hide.title")).toBeTruthy(); - }); - - it("does nothing when cancelled", async () => { - const { onConfirm } = renderVisibility("hide"); - - fireEvent.click( - await screen.findByRole("button", { - name: "core.global.confirm_action.cancel", - }), - ); - - await waitFor(() => { - expect(screen.queryByText("core.content.hide.title")).toBeNull(); - }); - expect(onConfirm).not.toHaveBeenCalled(); - }); -}); - -describe("ContentDuplicateDialog", () => { - it("copies only after the confirmation", async () => { - const onConfirm = vi.fn(async () => Promise.resolve(true)); - const onOpenChange = vi.fn(); - - render( - , - { wrapper }, - ); - - expect( - await screen.findByText("core.content.duplicate.title"), - ).toBeTruthy(); - expect(screen.getByText("core.content.duplicate.desc")).toBeTruthy(); - expect(onConfirm).not.toHaveBeenCalled(); - - fireEvent.click( - screen.getByRole("button", { name: "core.content.duplicate.confirm" }), - ); - - await waitFor(() => { - expect(onConfirm).toHaveBeenCalledTimes(1); - }); - await waitFor(() => { - expect(onOpenChange).toHaveBeenCalledWith(false); - }); - }); - - it("keeps the dialog open when the copy was refused", async () => { - const onConfirm = vi.fn(async () => Promise.resolve(false)); - const onOpenChange = vi.fn(); - - render( - , - { wrapper }, - ); - - fireEvent.click( - await screen.findByRole("button", { - name: "core.content.duplicate.confirm", - }), - ); - - await waitFor(() => { - expect(onConfirm).toHaveBeenCalledTimes(1); - }); - expect(onOpenChange).not.toHaveBeenCalledWith(false); - }); -}); diff --git a/packages/vitnode/src/views/admin/views/content/form/mutations-api.test.ts b/packages/vitnode/src/views/admin/views/content/form/mutations-api.test.ts index 7bea8324f9..c709c210e6 100644 --- a/packages/vitnode/src/views/admin/views/content/form/mutations-api.test.ts +++ b/packages/vitnode/src/views/admin/views/content/form/mutations-api.test.ts @@ -321,28 +321,6 @@ describe("picker options", () => { ]); }); - it("labels the options of a uuid or a bigint target by their keys", async () => { - answers(200, { - items: [ - { label: "Launch week", value: "047d2c6c-1d86-47e2-bcd3-2df9f5359541" }, - { label: "Big launch", value: "9007199254740994" }, - ], - }); - - await expect( - loadContentOptionsInBrowser(TARGET, "tags", "", [ - "047d2c6c-1d86-47e2-bcd3-2df9f5359541", - "9007199254740994", - ]), - ).resolves.toEqual([ - { label: "Launch week", value: "047d2c6c-1d86-47e2-bcd3-2df9f5359541" }, - { label: "Big launch", value: "9007199254740994" }, - ]); - expect(sent().query).toEqual({ - ids: "047d2c6c-1d86-47e2-bcd3-2df9f5359541,9007199254740994", - }); - }); - it("asks for identifiers instead of a search when given them", async () => { answers(200, { items: [] }); diff --git a/packages/vitnode/src/views/admin/views/content/form/visibility.test.tsx b/packages/vitnode/src/views/admin/views/content/form/visibility.test.tsx index 3e25cf7e23..1089a42cba 100644 --- a/packages/vitnode/src/views/admin/views/content/form/visibility.test.tsx +++ b/packages/vitnode/src/views/admin/views/content/form/visibility.test.tsx @@ -65,41 +65,27 @@ const renderForm = ( }; describe("ContentFormVisibilityToggle", () => { - it("hides a visible record after the confirmation", async () => { - const { transition } = renderForm(); + it.each([ + ["hide", null], + ["unhide", HIDDEN_AT], + ] as const)("asks before it calls %s", async (action, hiddenAt) => { + const { transition } = renderForm(, { + visibility: { hiddenAt }, + }); fireEvent.click( - screen.getByRole("button", { name: "core.content.actions.hide" }), + screen.getByRole("button", { name: `core.content.actions.${action}` }), ); - expect(await screen.findByText("core.content.hide.desc")).toBeTruthy(); + expect(await screen.findByText(`core.content.${action}.desc`)).toBeTruthy(); expect(transition).not.toHaveBeenCalled(); fireEvent.click( - screen.getByRole("button", { name: "core.content.hide.confirm" }), - ); - - await waitFor(() => { - expect(transition).toHaveBeenCalledWith("hide"); - }); - }); - - it("offers to unhide a hidden record", async () => { - const { transition } = renderForm(, { - visibility: { hiddenAt: HIDDEN_AT }, - }); - - fireEvent.click( - screen.getByRole("button", { name: "core.content.actions.unhide" }), - ); - fireEvent.click( - await screen.findByRole("button", { - name: "core.content.unhide.confirm", - }), + screen.getByRole("button", { name: `core.content.${action}.confirm` }), ); await waitFor(() => { - expect(transition).toHaveBeenCalledWith("unhide"); + expect(transition).toHaveBeenCalledWith(action); }); }); diff --git a/packages/vitnode/src/views/admin/views/content/table/cells.test.tsx b/packages/vitnode/src/views/admin/views/content/table/cells.test.tsx index 0f9eb9e864..22689d44d8 100644 --- a/packages/vitnode/src/views/admin/views/content/table/cells.test.tsx +++ b/packages/vitnode/src/views/admin/views/content/table/cells.test.tsx @@ -164,28 +164,29 @@ describe("ContentCell", () => { expect(screen.getByText("Hidden")).toBeTruthy(); }); - it("shows no badge for a visible record", () => { + it.each([ + [ + "a visible record", + { hiddenAt: null, status: "published" as const }, + labels, + ], + [ + "a content type without visibility", + { hiddenAt: "2026-10-01T09:00:00.000Z", status: "published" as const }, + { draft: "Draft", published: "Published" }, + ], + ])("shows no badge for %s", (_name, overrides, statusLabels) => { render( , ); expect(screen.getByText("Published")).toBeTruthy(); expect(screen.queryByText("Hidden")).toBeNull(); }); - - it("shows no badge for a content type without visibility", () => { - cell( - STATUS, - rowWith({ hiddenAt: "2026-10-01T09:00:00.000Z", status: "draft" }), - ); - - expect(screen.getByText("Draft")).toBeTruthy(); - expect(screen.queryByText("Hidden")).toBeNull(); - }); }); }); diff --git a/packages/vitnode/src/views/admin/views/content/table/list-filters.test.ts b/packages/vitnode/src/views/admin/views/content/table/list-filters.test.ts index 97fe81b99a..d9bbfe58d8 100644 --- a/packages/vitnode/src/views/admin/views/content/table/list-filters.test.ts +++ b/packages/vitnode/src/views/admin/views/content/table/list-filters.test.ts @@ -1,6 +1,5 @@ import { describe, expect, it } from "vitest"; -import { nextFilterSelection } from "@/components/table/filters"; import { defineContentType } from "@/content/define"; import { field } from "@/content/fields"; @@ -39,29 +38,18 @@ const notes = defineContentType({ }); describe("contentListToolbarFilters", () => { - it("offers status and visibility side by side, each one value at a time", () => { - const filters = contentListToolbarFilters(articles, LABELS); - - expect(filters.map(filter => filter.id)).toEqual(["status", "visibility"]); - expect(filters[1]).toEqual({ - id: "visibility", - label: "Visibility", - options: [ - { label: "Visible", value: "visible" }, - { label: "Hidden", value: "hidden" }, - ], - single: { allLabel: "All" }, - }); - }); - - it("offers status alone for a content type that does not hide", () => { + it.each([ + [ + "status and visibility for a content type that hides", + articles, + ["status", "visibility"], + ], + ["status alone for a content type that does not hide", pages, ["status"]], + ["nothing for a content type without publication", notes, []], + ])("offers %s", (_name, definition, ids) => { expect( - contentListToolbarFilters(pages, LABELS).map(filter => filter.id), - ).toEqual(["status"]); - }); - - it("offers nothing for a content type without publication", () => { - expect(contentListToolbarFilters(notes, LABELS)).toEqual([]); + contentListToolbarFilters(definition, LABELS).map(filter => filter.id), + ).toEqual(ids); }); }); @@ -88,18 +76,3 @@ describe("contentListSystemFilterValue", () => { expect(contentListSystemFilterValue("categoryId", "7")).toBe("7"); }); }); - -describe("nextFilterSelection", () => { - it("replaces the value of a single-value filter, and clears it on a second pick", () => { - expect(nextFilterSelection([], "hidden", true)).toEqual(["hidden"]); - expect(nextFilterSelection(["hidden"], "visible", true)).toEqual([ - "visible", - ]); - expect(nextFilterSelection(["hidden"], "hidden", true)).toEqual([]); - }); - - it("toggles one value of a multi-value filter, keeping the rest", () => { - expect(nextFilterSelection(["a"], "b")).toEqual(["a", "b"]); - expect(nextFilterSelection(["a", "b"], "a")).toEqual(["b"]); - }); -}); diff --git a/packages/vitnode/src/views/admin/views/content/table/list-query.test.ts b/packages/vitnode/src/views/admin/views/content/table/list-query.test.ts index 8573a5764a..c7510f35cf 100644 --- a/packages/vitnode/src/views/admin/views/content/table/list-query.test.ts +++ b/packages/vitnode/src/views/admin/views/content/table/list-query.test.ts @@ -226,41 +226,3 @@ describe("one page, as the table model", () => { expect(zodContentListPage.safeParse({ edges: [] }).success).toBe(false); }); }); - -describe("the list page", () => { - const pageInfo = { - count: 2, - currentPage: 1, - endCursor: null, - hasNextPage: false, - hasPreviousPage: false, - pageSize: 25, - startCursor: null, - totalCount: 2, - totalPages: 1, - }; - - it("reads rows of a uuid or a bigint content type with their ids intact", () => { - const page = zodContentListPage.parse({ - edges: [ - { id: "047d2c6c-1d86-47e2-bcd3-2df9f5359541", labels: {} }, - { id: "9007199254740994", labels: {} }, - ], - pageInfo, - }); - - expect(page.edges.map(row => row.id)).toEqual([ - "047d2c6c-1d86-47e2-bcd3-2df9f5359541", - "9007199254740994", - ]); - }); - - it("still reads a serial content type's rows as numbers", () => { - const page = zodContentListPage.parse({ - edges: [{ id: 7, labels: {} }], - pageInfo: { ...pageInfo, count: 1, totalCount: 1 }, - }); - - expect(page.edges[0]?.id).toBe(7); - }); -}); From 98a6adf49f8f6ca758940667dafbcf8b40c91f06 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 09:54:07 +0000 Subject: [PATCH 13/17] =?UTF-8?q?fix(core):=20=F0=9F=90=9B=20type=20onReor?= =?UTF-8?q?der=20ids=20by=20the=20row's=20own=20id=20type?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The shared data table's row id widened to number | string for uuid and bigint content, which broke onReorder callbacks typed number[]. The ids are the rows' own, so they now carry the row's id type and numeric tables keep number[]. Also formats content/types.ts. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VpG3D33ZqXEYjN2N6eNcMK --- packages/vitnode/src/components/table/reorder.tsx | 8 ++++++-- packages/vitnode/src/content/types.ts | 4 ++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/packages/vitnode/src/components/table/reorder.tsx b/packages/vitnode/src/components/table/reorder.tsx index e1eab9f462..2deae52c3a 100644 --- a/packages/vitnode/src/components/table/reorder.tsx +++ b/packages/vitnode/src/components/table/reorder.tsx @@ -33,11 +33,14 @@ import type { DataTableRowId, DataTableTMin } from "./data-table-content"; import { Button } from "../ui/button"; import { moveRowId, type ReorderDetailsDataTable } from "./reorder-state"; +/** The id type of one row - `number` for a table of numeric rows. */ +type RowIdOf = T extends { id: infer TId } ? TId : DataTableRowId; + export interface ReorderableDataTable { disabled?: boolean; getRowLabel?: (row: T) => string; onReorder: ( - ids: DataTableRowId[], + ids: RowIdOf[], details: ReorderDetailsDataTable, ) => Promise | void; } @@ -77,7 +80,8 @@ export const useRowOrderDataTable = ({ const persist = async () => { try { - await onReorder(move.ids, { + // The ids are the rows' own, so they carry the rows' id type. + await onReorder(move.ids as RowIdOf[], { activeId: move.activeId, from: move.from, overId: move.overId, diff --git a/packages/vitnode/src/content/types.ts b/packages/vitnode/src/content/types.ts index 53cfcc338f..d89abb50ca 100644 --- a/packages/vitnode/src/content/types.ts +++ b/packages/vitnode/src/content/types.ts @@ -392,7 +392,7 @@ type ScalarFieldValue = TField extends { kind: "boolean" } ? number : TField extends { kind: "richText" } ? RichTextDocument - : string; + : string; /** The scalar half of {@link ContentFieldInput}. `dateTime` crosses as ISO. */ type ScalarFieldInput = TField extends { kind: "boolean" } @@ -407,7 +407,7 @@ type ScalarFieldInput = TField extends { kind: "boolean" } ? number : TField extends { kind: "richText" } ? RichTextDocument - : string; + : string; /** Every leaf of a group, as it comes back. Nested, never flattened. */ type ContentGroupValue = Prettify<{ From cf244dcec199ea0d81d056862985e10d343f6673 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 10:04:28 +0000 Subject: [PATCH 14/17] =?UTF-8?q?docs(content):=20=F0=9F=93=9D=20document?= =?UTF-8?q?=20duplicate=20content,=20visibility=20and=20id=20strategies?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VpG3D33ZqXEYjN2N6eNcMK --- .../content-engine/admincp-row-actions.webp | Bin 0 -> 54232 bytes .../content/docs/content-engine/admincp.mdx | 19 ++- .../content/docs/content-engine/caching.mdx | 14 +- .../content-visibility-editor.webp | Bin 0 -> 24460 bytes .../content-visibility-filter.webp | Bin 0 -> 30524 bytes .../content-visibility-hide-dialog.webp | Bin 0 -> 20924 bytes .../content-engine/content-visibility.mdx | 160 ++++++++++++++++++ .../content/docs/content-engine/database.mdx | 56 ++++-- .../duplicate-content-dialog.webp | Bin 0 -> 21824 bytes .../docs/content-engine/duplicate-content.mdx | 152 +++++++++++++++++ .../content/docs/content-engine/editorial.mdx | 5 +- .../content/docs/content-engine/events.mdx | 8 +- .../content/docs/content-engine/fields.mdx | 4 +- .../docs/content-engine/id-strategy.mdx | 117 +++++++++++++ .../web/content/docs/content-engine/index.mdx | 7 +- .../docs/content-engine/live-editing-api.mdx | 2 + .../docs/content-engine/localization.mdx | 2 +- .../web/content/docs/content-engine/meta.json | 3 + .../docs/content-engine/production.mdx | 2 +- .../docs/content-engine/public-api.mdx | 2 +- .../docs/content-engine/public-pages.mdx | 2 +- .../content/docs/content-engine/reference.mdx | 93 +++++++--- .../content/docs/content-engine/relations.mdx | 35 ++++ .../content/docs/content-engine/search.mdx | 16 +- apps/web/content/docs/content-engine/seo.mdx | 3 +- .../content/docs/content-engine/services.mdx | 111 ++++++++++-- .../docs/dev/events/built-in-events.mdx | 100 +++++++++-- .../content/docs/dev/search-elasticsearch.mdx | 8 + apps/web/content/docs/dev/search.mdx | 5 +- apps/web/content/docs/guides/blog.mdx | 10 ++ 30 files changed, 848 insertions(+), 88 deletions(-) create mode 100644 apps/web/content/docs/content-engine/admincp-row-actions.webp create mode 100644 apps/web/content/docs/content-engine/content-visibility-editor.webp create mode 100644 apps/web/content/docs/content-engine/content-visibility-filter.webp create mode 100644 apps/web/content/docs/content-engine/content-visibility-hide-dialog.webp create mode 100644 apps/web/content/docs/content-engine/content-visibility.mdx create mode 100644 apps/web/content/docs/content-engine/duplicate-content-dialog.webp create mode 100644 apps/web/content/docs/content-engine/duplicate-content.mdx create mode 100644 apps/web/content/docs/content-engine/id-strategy.mdx diff --git a/apps/web/content/docs/content-engine/admincp-row-actions.webp b/apps/web/content/docs/content-engine/admincp-row-actions.webp new file mode 100644 index 0000000000000000000000000000000000000000..31289f3057385a3fdd142aaa6619a96716d8d503 GIT binary patch literal 54232 zcmb?>WprH2v9*|)nVHdIW@fZxfhAeY%*@OdGc(I#W(JFynOU08H_5%pOYTeZ*81kp z%sPF#Po3_nUAuOTvXr>^^c)b7hM2ITx*{i0|F7S+SYVmJG}Dk|VEoKj;i4qOxg
    lgJe*-wbmQ&*cn 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�~)Hv9bJ+L?qGC=*4 z{z0~d0jTj4t55Xp`_KZ>eft~n!W2%{VP}t#mK+*=S|%DPBnw)R#iniLv*JxasNhlC d$7Ccx000015p!cO+yHD?k*xO?eoa6C008f}SRMcX literal 0 HcmV?d00001 diff --git a/apps/web/content/docs/content-engine/admincp.mdx b/apps/web/content/docs/content-engine/admincp.mdx index a6b429b80e..2c8da2d6e6 100644 --- a/apps/web/content/docs/content-engine/admincp.mdx +++ b/apps/web/content/docs/content-engine/admincp.mdx @@ -8,6 +8,7 @@ import { ImgDocs } from "@/components/fumadocs/img"; import articleList from "./admincp-article-list.webp"; import bulkActions from "./admincp-bulk-actions.webp"; import createDialog from "./admincp-create-dialog.webp"; +import rowActions from "./admincp-row-actions.webp"; Every content type registered in the plugin's `src/admin/content.tsx` gets an AdminCP list and a create and edit form without any UI code. Most changes are options in the definition's `admin` block. For anything the options cannot express, you swap one cell, one field or the whole form layout for your own component. @@ -84,6 +85,22 @@ Ticking rows opens a floating bar with **Publish**, **Unpublish** and **Delete** Each action asks for confirmation and then sends one request per record to the same route the row buttons use, so events, search indexing and revisions behave as if you clicked every row. When some records fail, for example because someone else saved them meanwhile, the rest still go through. A toast says how many were skipped, and those rows stay ticked. +## Duplicate and hide records + +The **More actions** menu of each row collects the less frequent actions. Two of them come from options in the definition: + +- **Duplicate** appears with [`duplication`](/docs/content-engine/duplicate-content), for staff with `can_create` and `can_view`. It makes a draft copy and opens it. +- **Hide** or **Unhide** appears with [`visibility`](/docs/content-engine/content-visibility), for staff with `can_hide`. A hidden record gets a **Hidden** badge next to its status, and the toolbar gets a **Visibility** filter with **All**, **Visible** and **Hidden**. + + + +Each action asks for confirmation, refreshes the list and shows a toast. On the edit page, `ContentFormActions` adds a **Hide** or **Unhide** button next to **Save changes**, and `ContentFormStatus` shows the same **Hidden** badge with a short explanation. + ## Group form fields into sections By default the form shows every field except `field.blocks()`, one after another, in a dialog: @@ -264,7 +281,7 @@ contentTypeAdmin({ }), ``` -`forms.create.layout` and `forms.edit.layout` override the shared one per action. `ContentFormRemainingFields` renders every form field you did not place yourself. In development, the browser console warns about fields the layout never rendered and about a missing `ContentFormHeader`, which holds the title and the back link. +`forms.create.layout` and `forms.edit.layout` override the shared one per action. For a content type with [`visibility`](/docs/content-engine/content-visibility), `ContentFormHiddenBadge`, `ContentFormHiddenNotice` and `ContentFormVisibilityToggle` from the same import place the badge, the explanation and the button yourself. The blog's editor puts `ContentFormHiddenNotice` under its header. `ContentFormRemainingFields` renders every form field you did not place yourself. In development, the browser console warns about fields the layout never rendered and about a missing `ContentFormHeader`, which holds the title and the back link. ## Show a matching skeleton diff --git a/apps/web/content/docs/content-engine/caching.mdx b/apps/web/content/docs/content-engine/caching.mdx index 7516668059..6ae2a11ae1 100644 --- a/apps/web/content/docs/content-engine/caching.mdx +++ b/apps/web/content/docs/content-engine/caching.mdx @@ -90,7 +90,7 @@ When a front end caches public reads under tags, it needs to know which tags a w | `contentDeliveryRedirectTag(typeId, slug, locale?)` | `content:example.article:redirect:hello-content-engine` | | `contentDeliverySitemapTag(typeId, locale?)` | `content:example.article:sitemap` | -A locale goes after the kind, so a localized type's Polish list is `content:example.localized-article:list:pl`. +A locale goes after the kind, so a localized type's Polish list is `content:example.localized-article:list:pl`. An id goes in as text, so a [uuid or bigint](/docs/content-engine/id-strategy) record's item tag carries its exact UUID or decimal string. `contentInvalidationTags` turns a description of one write into the exact tags to expire. Publishing article `3` returns all six tags above: @@ -121,13 +121,21 @@ export const vitNodeApiConfig = buildApiConfig({ }); ``` -VitNode then sends a `POST` to `{origin}/api/vitnode/content/revalidate` when a [scheduled](/docs/content-engine/editorial#schedule-publishing) publish or unpublish runs. Publishing by hand in the AdminCP sends nothing today, so a front end that caches must also expire entries on its own timer. +VitNode then sends a `POST` to `{origin}/api/vitnode/content/revalidate` in these cases: + +| Change | Notifies origins | +| ------------------------------------------------------------------------------------------- | ---------------------------------------------- | +| A [scheduled](/docs/content-engine/editorial#schedule-publishing) publish or unpublish runs | Yes | +| A record is [hidden or unhidden](/docs/content-engine/content-visibility) | Yes, when that changes what the public can see | +| A publish, unpublish, edit or delete from the AdminCP or the API | No | + +Hiding a draft notifies nobody, because nothing public changed. For the changes that send nothing, a front end that caches must expire entries on its own timer, or from a listener for the [content events](/docs/content-engine/events). The request carries: - `Authorization: Bearer `, the same secret that runs cron jobs. - `x-vitnode-timestamp`, the send time in milliseconds. -- A JSON body with the `contentInvalidationTags` input plus `mode`, which is always `"immediate"` for a scheduled run. +- A JSON body with the `contentInvalidationTags` input plus `mode`, which is always `"immediate"` for a scheduled run, a hide and an unhide. The secret travels with the request, so outside development each origin must use `https`. Plain `http` is allowed only for `localhost` and `127.0.0.1`. VitNode sends nothing while `CRON_SECRET` is unset or still the default. It tries each origin twice and does not retry a `403`, which it reads as a wrong secret. diff --git a/apps/web/content/docs/content-engine/content-visibility-editor.webp b/apps/web/content/docs/content-engine/content-visibility-editor.webp new file mode 100644 index 0000000000000000000000000000000000000000..c62a3136c51bf6c48153d86c9361e7de5013ef04 GIT binary patch literal 24460 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 literal 0 HcmV?d00001 diff --git a/apps/web/content/docs/content-engine/content-visibility-filter.webp b/apps/web/content/docs/content-engine/content-visibility-filter.webp new file mode 100644 index 0000000000000000000000000000000000000000..84c77dbfec9a114503a07e34a9d159bae4665bd9 GIT binary patch literal 30524 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 E09@@XrT_o{ literal 0 HcmV?d00001 diff --git a/apps/web/content/docs/content-engine/content-visibility-hide-dialog.webp b/apps/web/content/docs/content-engine/content-visibility-hide-dialog.webp new file mode 100644 index 0000000000000000000000000000000000000000..ac3467dc098a5bf7c593514599bdb05dba6720b6 GIT binary patch literal 20924 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 + + + +### Turn on visibility + +The blog's article content type turns it on next to `publication`: + +```ts title="plugins/blog/src/content/post.ts" +export const blogPostContentType = defineContentType({ + id: "blog.post", + tableName: "blog_posts", + publication: true, + visibility: true, // [!code ++] + // ... +}); +``` + +`visibility` takes only `true` or `false`. Without `publication` it throws when the content type is defined. With it, `hiddenAt`, `hiddenBy` and `visibility` are reserved and cannot be field names. + + + + + +### Build and migrate + +Run `pnpm build:plugins && pnpm db:migrate`, then restart `pnpm dev`. The migration adds two nullable columns to the main table. For the blog, `apps/api/migrations/20261011093549_content_visibility_id_strategies` contains: + +```sql title="migration.sql (excerpt)" +ALTER TABLE "blog_posts" ADD COLUMN "hiddenAt" timestamp; +ALTER TABLE "blog_posts" ADD COLUMN "hiddenBy" integer; +CREATE INDEX "blog_posts_hidden_by_idx" ON "blog_posts" ("hiddenBy"); +ALTER TABLE "blog_posts" ADD CONSTRAINT "blog_posts_hiddenBy_core_users_id_fkey" FOREIGN KEY ("hiddenBy") REFERENCES "core_users"("id") ON DELETE SET NULL ON UPDATE CASCADE; +``` + +Existing records stay visible. `hiddenAt` is when the record was hidden and `hiddenBy` is the staff member who hid it. + + + + + +### Label the permission + +Visibility adds a `can_hide` permission, separate from `can_publish`, so a moderator can take records down without being able to publish. The blog labels it next to its other article permissions: + +```json title="plugins/blog/src/locales/en.json" +{ + "@vitnode/blog:posts:can_hide": "Hide and unhide articles" +} +``` + +Grant it to the staff who should moderate in **AdminCP → Staff**. + + + + + +### Hide an article + +Open **AdminCP → Blog → Articles**, open the **More actions** menu of a published article and choose **Hide**. Confirm with **Yes, hide it**: + + + +A toast says the article is now hidden. The row keeps its **Published** badge and gets a **Hidden** badge next to it, whose tooltip shows when it was hidden. The **Visibility** filter in the list toolbar shows only hidden or only visible records, and it combines with the **Status** filter: + + + +The edit page shows the same badge, an **Unhide** button and a note that publishing a hidden record does not make it public: + + + +Choose **Unhide** in the same menu, or on the edit page, to bring the article back. In the database, hiding sets `blog_posts."hiddenAt"` and `"hiddenBy"`, and unhiding clears both. + + + + + +## What hiding does + +A record is public only when it is published, its `publishedAt` is not in the future, and it is not hidden. While a record is hidden: + +- The public API list leaves it out and its detail route answers `404`, in every language, including a language that falls back to the default one. +- Its [page](/docs/content-engine/public-pages) answers `404`, and so do its old slugs instead of redirecting. +- The [sitemap](/docs/content-engine/seo#sitemap) and `hreflang` alternates leave it out, and [site search](/docs/content-engine/search) marks its document private. +- Signed [preview links](/docs/content-engine/editorial#preview-links) still work, and the AdminCP still shows it. + +Hiding changes nothing else. `status` and `publishedAt` stay as they are, so unhiding a published record puts it back with its original date. Publishing, unpublishing or a scheduled publish never change visibility: publishing a hidden record keeps it off the site until someone unhides it. Restoring a revision never changes visibility either. + +With [`editorial`](/docs/content-engine/editorial), hiding and unhiding raise the record's `version` and store a revision, shown as **Hidden** or **Unhidden** in the history. Hiding a record that is already hidden changes nothing: no write, no revision and no event. + +Hiding is per record. To take one language off the site, unpublish that translation instead. + +## Hide from the API + +The admin module has `POST /{id}/hide` and `POST /{id}/unhide`, such as `POST /api/@vitnode/blog/admin/content/posts/703/hide` for the blog. Both need `can_hide` and accept an optional body: + +```json +{ "expectedVersion": 4 } +``` + +With `expectedVersion`, the call fails with `409` and `CONTENT_VERSION_CONFLICT` when someone saved the record meanwhile, like any [editorial write](/docs/content-engine/editorial#safe-concurrent-edits). Both routes answer `200` with `{ changed, row }`, where `changed` is `false` when the record already was in that state. A malformed id is `400` and a missing record is `404`. With `delivery.redirects`, unhiding can also fail with `409` when another record took the URL while this one was hidden. + +The admin list route accepts `?visibility=hidden` or `?visibility=visible`, and it combines with `?status=`. + +From your own code, `hide(id, options)` and `unhide(id, options)` exist on the services when visibility is on. See [Services](/docs/content-engine/services#hide-duplicate-and-content-ids). + +## Events and caches + +A hide emits `content.blog.post.hidden` with `{ actorUserId, contentId, hiddenAt }`, and an unhide emits `unhidden` with `{ actorUserId, contentId }`. Both update the search index and the delivery data. See [Events](/docs/content-engine/events). + +Front ends listed in [`content.revalidateOrigins`](/docs/content-engine/caching#notify-a-separate-front-end) are notified at once when a hide or unhide changes what the public can see. Hiding a draft notifies nobody, because nothing public changed. + +## Check the result + +Hide the published "Second post", then request its page and its API entry: + +```bash +curl -o /dev/null -w "%{http_code}\n" "http://localhost:3000/blog/second-post" +curl -o /dev/null -w "%{http_code}\n" "http://localhost:3000/api/@vitnode/blog/content/blog/second-post" +``` + +Both answer `404`, like a draft, and the article drops out of the blog list and the sitemap. In the AdminCP it is still **Published**. Unhide it, and the same requests answer `200` with the original `publishedAt`. diff --git a/apps/web/content/docs/content-engine/database.mdx b/apps/web/content/docs/content-engine/database.mdx index 778bac50cd..135512b1a6 100644 --- a/apps/web/content/docs/content-engine/database.mdx +++ b/apps/web/content/docs/content-engine/database.mdx @@ -78,14 +78,16 @@ export const example_advanced_articles_faq = You never declare these. A field with the same name throws an error. -| Column | Type | Added when | -| ------------- | ----------------------------------------------------------- | ------------- | -| `id` | `serial` primary key | Always | -| `createdAt` | `timestamp`, defaults to `now()` | Always | -| `updatedAt` | `timestamp`, defaults to `now()`, refreshed by every update | Always | -| `status` | `varchar(32)`, `draft` or `published`, defaults to `draft` | `publication` | -| `publishedAt` | `timestamp`, nullable | `publication` | -| `version` | `integer`, defaults to `1` | `editorial` | +| Column | Type | Added when | +| ------------- | ------------------------------------------------------------------------- | ------------- | +| `id` | Primary key, typed by [`idStrategy`](#ids-and-shared-tables) | Always | +| `createdAt` | `timestamp`, defaults to `now()` | Always | +| `updatedAt` | `timestamp`, defaults to `now()`, refreshed by every update | Always | +| `status` | `varchar(32)`, `draft` or `published`, defaults to `draft` | `publication` | +| `publishedAt` | `timestamp`, nullable | `publication` | +| `version` | `integer`, defaults to `1` | `editorial` | +| `hiddenAt` | `timestamp`, nullable | `visibility` | +| `hiddenBy` | `integer`, nullable, references `core_users.id` with `ON DELETE SET NULL` | `visibility` | Column names are camelCase in SQL, exactly as in TypeScript. Every generated table has row-level security enabled. A field group adds one column per leaf to the main table, so the `syndication` group's `indexable` field becomes `syndicationIndexable`. Column types per field are listed in [Fields](/docs/content-engine/fields). @@ -93,13 +95,13 @@ Column names are camelCase in SQL, exactly as in TypeScript. Every generated tab With `localization`, `{tableName}_translations` holds the localized fields: -| Column | Notes | -| ------------------------ | ------------------------------------------------------------------------- | -| `itemId` | References the main table. Deleting the record deletes its translations | -| `languageId` | References `core_languages.id`. A language with content cannot be deleted | -| `version` | Per-language version, always present | -| `createdAt`, `updatedAt` | Same as the main table | -| `status`, `publishedAt` | With `publication`. Each language is published on its own | +| Column | Notes | +| ------------------------ | ------------------------------------------------------------------------------------------------------- | +| `itemId` | References the main table, with the same type as its `id`. Deleting the record deletes its translations | +| `languageId` | References `core_languages.id`. A language with content cannot be deleted | +| `version` | Per-language version, always present | +| `createdAt`, `updatedAt` | Same as the main table | +| `status`, `publishedAt` | With `publication`. Each language is published on its own | The primary key is `(itemId, languageId)`, so a record has at most one translation per language. @@ -112,12 +114,32 @@ Field names become snake_case in table names, so `relatedArticles` on `example_a Deleting a record deletes its junction and repeatable rows. Both kinds keep `position` unique per record. That column holds the order an editor picks in the AdminCP. +Every column that points at a record has the type of that record's key. In a junction table, `itemId` follows the owner's `idStrategy` and `relatedItemId` follows the target's. A repeatable's own `id` is always `serial`, while its `itemId` follows the owner. User and file references are always `integer`. + +## IDs and shared tables + +The [id strategy](/docs/content-engine/id-strategy) sets the key column: + +| `idStrategy` | `id` column | +| -------------------- | ----------------------------------------------------- | +| `"serial"` (default) | `serial PRIMARY KEY` | +| `"uuid"` | `uuid PRIMARY KEY DEFAULT gen_random_uuid()` | +| `"bigint"` | `bigint PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY` | + +Core tables that hold records of every content type store the id as text: `core_content_revisions`, `core_content_schedules`, `core_content_slug_history`, `core_search_index`, the [live editing](/docs/content-engine/live-editing-api#where-the-data-lives) tables `core_content_drafts`, `core_content_field_locks` and `core_content_documents`, and `core_ai_translation_sources`. Their `itemId` is a `varchar(64)` holding `contentIdKey(id)`: the digits of a serial or bigint id, or the UUID. Migration `20261011093549_content_visibility_id_strategies` converted them in place from `integer`, so existing serial keys keep their digits: + +```sql title="apps/api/migrations/20261011093549_content_visibility_id_strategies/migration.sql (excerpt)" +ALTER TABLE "core_content_revisions" ALTER COLUMN "itemId" SET DATA TYPE varchar(64) USING "itemId"::varchar(64); +``` + +Custom SQL that joins these tables to a content table needs a cast, such as `"core_content_revisions"."itemId" = "example_articles"."id"::text`. + ## Generated indexes The engine creates these indexes without being asked: - A unique index on every slug, and on every `field.text({ unique: true })`. -- An index on every single `relation`, `user` and `file` column. +- An index on every single `relation`, `user` and `file` column, and on `hiddenBy` with `visibility`. - Indexes on `createdAt` and `updatedAt`. - `(status, publishedAt)` with `publication`. - On a translations table: `(languageId, status)` with `publication` or `(languageId)` without it, plus a unique `(languageId, slug)` for each localized slug. @@ -137,4 +159,6 @@ Changing a definition is a schema change like any other. Build, migrate and read - **Adding a required field without a default** fails on a table with rows. Add it as nullable, fill it, then make it required in a second migration. - **Renaming a field** renames its column. Check that the generated SQL says `RENAME COLUMN`, not a drop followed by an add, or the old values are lost. - **Turning on `localization`** moves localized fields to a new translations table. The generated SQL does not copy existing values, so add the data move to the migration yourself. +- **Turning on `visibility`** adds two nullable columns, so existing records stay visible. +- **Changing `idStrategy`** on a table with data is not something the generated SQL can do. It needs a hand-written data migration that converts the key, every column that references it and the keys in the shared tables. See [Change the strategy later](/docs/content-engine/id-strategy#change-the-strategy-later). - **Never edit or regenerate a migration that has already been applied.** Write a new one instead. diff --git a/apps/web/content/docs/content-engine/duplicate-content-dialog.webp b/apps/web/content/docs/content-engine/duplicate-content-dialog.webp new file mode 100644 index 0000000000000000000000000000000000000000..5dd73ceef84dc0f14fb2c248d0e4f5b93917cbdb GIT binary patch literal 21824 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 + + + +### Turn on duplication + +Add `duplication: true` to the definition. The blog's article content type does exactly that: + +```ts title="plugins/blog/src/content/post.ts" +export const blogPostContentType = defineContentType({ + id: "blog.post", + tableName: "blog_posts", + publication: true, + duplication: true, // [!code ++] + // ... +}); +``` + +Duplication adds no column, so there is nothing to migrate, and no new permission. Duplicating needs `can_create` and `can_view`, because it creates a record by reading another: for the blog, **Create articles** and **View articles list**. Run `pnpm build:plugins` and restart `pnpm dev`. + + + + + +### Duplicate an article in the AdminCP + +Open **AdminCP → Blog → Articles**, open the **More actions** menu of an article and choose **Duplicate**. Confirm with **Yes, duplicate it**: + + + +The list refreshes, a toast says the article was duplicated as a draft, and the copy opens on its edit page (or in the edit dialog, for a content type whose `admin.edit.mode` is `"dialog"`). If some of the source's languages are switched off on this site, a second toast lists the ones that were left out. + + + + + +Duplicating the blog's "Hello world", published in English and Polish with its categories and authors, creates a draft with: + +| Language | Title | Slug | +| -------- | -------------------- | -------------------- | +| English | Hello world (Copy) | `hello-world-copy` | +| Polish | Witaj świecie (Copy) | `witaj-swiecie-copy` | + +The copy has the same categories and the same authors in the same order, and each language starts its own revision history. + +## What the copy contains + +| Copied | Not copied | +| ---------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------- | +| Every declared field, including rich text, groups and blocks | Revisions, schedules and preview links | +| Repeatable rows, as new rows in the same order | Slug history and redirects | +| Relation, user and file references, in their order. Files are reused, not uploaded again | Published state and `publishedAt`: the copy is a draft | +| Every translation, each as a draft with its own first revision | Hidden state: the copy is never [hidden](/docs/content-engine/content-visibility) | + +The copy gets a new id, new timestamps and `version` 1. Everything happens in one database transaction, so a failure leaves nothing behind. The source is only read: its history gets no new revision. + +### Titles and slugs + +When `admin.titleField` is a `field.text()`, the copy's title gets a suffix in each language, such as "Hello world (Copy)". The source title is shortened when the suffix would push it past the field's `maxLength`. The suffix is the core API message `core.content.duplicate.title_suffix`, resolved in each copied language and falling back to "(Copy)". + +Turn the suffix off with `duplication: { titleSuffix: false }`. `titleSuffix: true` with a title field that is not a text field throws when the content type is defined. + +Each slug is regenerated from the copy's title, so `hello-world` becomes `hello-world-copy`. A slug without a `source` gets `-copy` appended. When that slug is taken, by a live record or by another record's old URL, the engine tries `-2`, `-3` and so on, up to 20 candidates. + +### Unique fields + +A `field.text({ unique: true })` cannot keep the source's value. Duplicating such a record without a new value fails before anything is written, with `422` and the code `CONTENT_DUPLICATE_UNIQUE_REQUIRED`. Pass the new value as an override from the API, as shown below. + +## Duplicate from the API + +The admin module of a content type with duplication has `POST /{id}/duplicate`. For the blog, the module is named after its permission module, `posts`: + +```http +POST /api/@vitnode/blog/admin/content/posts/701/duplicate +Content-Type: application/json + +{ + "translations": { + "en": { "title": "Hello again", "friendlyUrl": "hello-again" }, + "pl": { "title": "Witaj ponownie" } + } +} +``` + +The body is optional: + +| Name | Type | Description | +| -------------- | ---------------------------------------- | ------------------------------------------------------------------------------------------------------ | +| `overrides` | Partial shared field values | Values the copy takes instead of the source's. A group is merged leaf by leaf. Validated like a create | +| `translations` | `{ [locale]: partial localized values }` | Localized values per copied language. A language the source has no translation in is a `400` | + +A slug you pass is used as it is, without `-copy`, and a title you pass is used without the "(Copy)" suffix. In the example above, the English copy is "Hello again" at `hello-again`, and the Polish copy is "Witaj ponownie" with the slug `witaj-ponownie` generated from that title. + +The response is `201` with `{ id, sourceId, row, locales, skippedLocales }`: the copy's id, the source id, the new row, the copied languages and the languages left out because they are switched off. + +| Status | When | +| ------ | ------------------------------------------------------------------------------------------------------------------------------------------ | +| `400` | An invalid id, override or translation | +| `403` | Missing `can_create` or `can_view` | +| `404` | The source does not exist | +| `409` | No free slug was found (`CONTENT_DUPLICATE_SLUG_CONFLICT`, with `field`, `locale` and `slug`), or an override collides with another record | +| `422` | A unique field needs an override (`CONTENT_DUPLICATE_UNIQUE_REQUIRED`, with `fields`) | + +## Duplicate from your own code + +`service(c).duplicate(sourceId, options)` and `editorialService(c, { pluginId }).duplicate(sourceId, { actor, ...options })` take the same `overrides` and `translations`, plus `tx`. Both exist only when duplication is on, and both return `null` when the source does not exist. + +The services do not emit events or index the copy. Run `contentDuplicateEffects` from `@vitnode/core/content/server` after the transaction commits, as the generated route does: + +```ts +import { contentDuplicateEffects, resolveContentActor } from "@vitnode/core/content/server"; + +const { editorialService } = postContent; +if (!editorialService) throw new Error("Articles have no editorial features."); + +const pluginId = CONFIG_PLUGIN.pluginId; +const actor = resolveContentActor(c); +const outcome = await editorialService(c, { pluginId }).duplicate(sourceId, { + actor, +}); + +if (outcome) { + await contentDuplicateEffects( + c, + postContent, + { kind: "editorial", outcome }, + { actorUserId: actor.userId, pluginId }, + ); +} +``` + +The effects emit `content.blog.post.created` with `duplicatedFromId`, one `translation_created` per copied language, and then `content.blog.post.duplicated`. They also add the copy to the search index as a private draft. See [Events](/docs/content-engine/events). + +## Check the result + +Duplicate "Hello world" and go back to the list. "Hello world (Copy)" is a **Draft**, and `/blog/hello-world-copy` answers `404` until you publish it. Open the original's **History**: it has no new revision. diff --git a/apps/web/content/docs/content-engine/editorial.mdx b/apps/web/content/docs/content-engine/editorial.mdx index d4c96eeb2a..8e3bf659fd 100644 --- a/apps/web/content/docs/content-engine/editorial.mdx +++ b/apps/web/content/docs/content-engine/editorial.mdx @@ -64,7 +64,7 @@ The migration adds a `version` column to `example_articles`. Revisions, schedule ## Revisions -Every create, update, publish, unpublish and restore stores a snapshot in `core_content_revisions`. `revisions.retention` keeps the newest snapshots per record, 50 by default and at most 500. +Every create, update, publish, unpublish and restore stores a snapshot in `core_content_revisions`, and so does every hide and unhide with [`visibility`](/docs/content-engine/content-visibility). `revisions.retention` keeps the newest snapshots per record, 50 by default and at most 500. To see them, open the row's **More actions** menu in the AdminCP list and choose **History**. Each version shows who saved it and when, and **Show changes** lists the fields it changed: @@ -75,7 +75,7 @@ To see them, open the row's **More actions** menu in the AdminCP list and choose alt="History of this Article dialog: v3 by Tom Becker expanded to show the title change from Spring menu launch to Spring menu launches Monday and Featured switched on, with Restore buttons on v2 and v1" /> -**Restore** puts that version's field values back as a new version, so nothing in between is lost and the publication status stays as it is. It needs `can_restore` and emits `content.example.article.restored`. Anyone with the record open in [live editing](/docs/content-engine/live-editing) gets a notice and a reloaded form. +**Restore** puts that version's field values back as a new version, so nothing in between is lost. The publication status and the [hidden](/docs/content-engine/content-visibility) state stay as they are. It needs `can_restore` and emits `content.example.article.restored`. Anyone with the record open in [live editing](/docs/content-engine/live-editing) gets a notice and a reloaded form. ## Safe concurrent edits @@ -146,5 +146,6 @@ A few rules keep schedules sane: - A time up to two minutes in the past is accepted. Anything older fails with `CONTENT_SCHEDULE_IN_PAST`. - An unpublish cannot come before a pending publish (`CONTENT_SCHEDULE_ORDER`). - Booking emits `content.example.article.scheduled` and cancelling emits `schedule_cancelled`. When the time comes, the usual `published` or `unpublished` event carries the `scheduleId`. +- A scheduled publish of a [hidden](/docs/content-engine/content-visibility) record leaves it hidden, so it stays off the public site until someone unhides it. If a separate front end caches pages, [Notify a separate front end](/docs/content-engine/caching#notify-a-separate-front-end) tells it about the change. diff --git a/apps/web/content/docs/content-engine/events.mdx b/apps/web/content/docs/content-engine/events.mdx index e2100951a4..d7f2efea66 100644 --- a/apps/web/content/docs/content-engine/events.mdx +++ b/apps/web/content/docs/content-engine/events.mdx @@ -14,7 +14,7 @@ Which events exist depends on what the definition enables. `example.category` de | Action | Payload | Needs | | --------------------------- | ----------------------------------------------------------------------------------------------- | ------------------------------ | -| `created` | `{ contentId }` | always | +| `created` | `{ contentId, duplicatedFromId? }` | always | | `updated` | `{ contentId, changedFields }` | always | | `deleted` | `{ contentId }` | always | | `published` | `{ contentId, publishedAt, scheduledBy?, scheduleId? }` | `publication` | @@ -30,9 +30,15 @@ Which events exist depends on what the definition enables. `example.category` de | `translation_restored` | `{ contentId, locale, languageId, version, changedFields, revisionId, restoredFromRevisionId }` | `localization` + `editorial` | | `delivery_slug_changed` | `{ contentId, slug, previousSlug, previousPath, canonicalPath, locale }` | `delivery` | | `delivery_redirect_created` | `{ contentId, previousSlug, previousPath, canonicalPath, locale }` | `delivery` | +| `duplicated` | `{ actorUserId, contentId, contentTypeId, sourceId }` | `duplication` | +| `hidden` | `{ actorUserId, contentId, hiddenAt }` | `visibility` | +| `unhidden` | `{ actorUserId, contentId }` | `visibility` | A few payload details that change what a listener does: +- `contentId` has the type of the content type's [id strategy](/docs/content-engine/id-strategy): a `number` for `serial`, a `string` for `uuid` and `bigint`. A bigint id arrives as its exact decimal string. +- A [duplicate](/docs/content-engine/duplicate-content) emits the copy's `created` with `duplicatedFromId`, one `translation_created` per copied language, then `duplicated`. `duplicatedFromId` is absent on an ordinary create. +- `hidden` and `unhidden` never replace `published` or `unpublished`, because [hiding](/docs/content-engine/content-visibility) does not change the status. - `changedFields` lists only the fields whose value actually moved, typed as the content type's own field names. On translation events it names localized fields only. - `restored` replaces `updated` for a rollback, so a listener that cares about field changes should handle both. - `publishedAt` is when the record first went live. It is never rewritten, and it can be missing, so guard for `undefined`. diff --git a/apps/web/content/docs/content-engine/fields.mdx b/apps/web/content/docs/content-engine/fields.mdx index 3b122e8ec7..bc19a626e8 100644 --- a/apps/web/content/docs/content-engine/fields.mdx +++ b/apps/web/content/docs/content-engine/fields.mdx @@ -58,7 +58,7 @@ The second half holds the references: a user picker, two file dropzones and a re | `field.slug()` | `varchar(maxLength)`, 160 by default, unique | Text input | Yes | | `field.user()` | `integer` referencing `core_users.id` | User picker | No | | `field.file()` | `integer` referencing `core_files.id` | File dropzone | No | -| `field.relation()` | `integer` referencing the target table | Search picker | No | +| `field.relation()` | The target's key type, referencing its table | Search picker | No | | `field.group()` | One column per leaf | A fieldset with the leaf controls | Yes, as a whole | | `field.repeatable()` | A child table | A list of rows you can add, move, remove | No | | `field.blocks()` | `jsonb`, `NOT NULL DEFAULT '[]'` | Not in the form; edited as widgets | Yes | @@ -228,7 +228,7 @@ category: field.relation({ required: true, onDelete: "restrict", target: () => c | `min` | `number` | No | | Fewest targets, 1 to 500. Needs `multiple` | | `onDelete` | `"restrict"` \| `"cascade"` \| `"set null"` | No | `"restrict"` | What deleting the target does. `"set null"` needs a nullable single relation | -A single relation must be `required` or `nullable`. A to-many relation can be neither. [Relations](/docs/content-engine/relations) shows the model wiring and the junction tables. +A single relation must be `required` or `nullable`. A to-many relation can be neither. The column has the type of the target's key: `integer`, `uuid` or `bigint`, depending on its [`idStrategy`](/docs/content-engine/id-strategy). [Relations](/docs/content-engine/relations) shows the model wiring and the junction tables. ## Group diff --git a/apps/web/content/docs/content-engine/id-strategy.mdx b/apps/web/content/docs/content-engine/id-strategy.mdx new file mode 100644 index 0000000000..36a4c2e862 --- /dev/null +++ b/apps/web/content/docs/content-engine/id-strategy.mdx @@ -0,0 +1,117 @@ +--- +title: Record ID strategies +description: Choose how a VitNode Content Engine content type keys its records - serial integers, UUIDs or bigint - and how those ids look in Postgres, JSON, URLs, relations and your own code. +icon: KeyRound +--- + +`idStrategy` decides the type of a content type's primary key. The default, `"serial"`, is an auto-incrementing integer. `"uuid"` gives random ids that reveal nothing about how many records exist. `"bigint"` gives integers that will not run out. Pick it when you create the content type, because changing it later is a [hand-written data migration](#change-the-strategy-later). + +The example plugin keys its tags by UUID: + +```ts title="plugins/example/src/content/tag.ts" +export const tagContentType = defineContentType({ + id: "example.tag", + tableName: "example_tags", + idStrategy: "uuid", // [!code ++] + localization: { defaultLocale: "en", fallback: "default" }, + publication: true, + fields: { + name: field.text({ localized: true, required: true, minLength: 1, maxLength: 100 }), + slug: field.slug({ localized: true, source: "name" }), + // category and relatedTags relations + }, + // editorial, visibility, duplication, publicApi and admin +}); +``` + +## Strategies + +| Strategy | Postgres key | In JSON and URLs | In TypeScript | +| -------------------- | -------------------------------------------------------------------------- | ------------------------------------------------------------ | ------------- | +| `"serial"` (default) | `serial`, up to 2,147,483,647 | A number: `42` | `number` | +| `"uuid"` | `uuid`, `DEFAULT gen_random_uuid()` | A lowercase string: `"047d2c6c-1d86-47e2-bcd3-2df9f5359541"` | `string` | +| `"bigint"` | `bigint GENERATED BY DEFAULT AS IDENTITY`, up to 9,223,372,036,854,775,807 | A decimal string: `"9007199254740994"` | `string` | + +Choose by what the ids are for: + +- **`serial`** suits most content. Ids are short and readable in URLs and logs. The blog's articles and categories use it. +- **`uuid`** suits content whose ids should not be guessable or countable, or records created in several places that must not collide. The example plugin's `example.tag` uses it. +- **`bigint`** suits tables that may outgrow two billion rows, such as events or logs. The example plugin's `example.event` uses it. + +Both example types appear in **AdminCP → Example → Tags** and **Events**, and their edit links carry the UUID or the decimal string, such as `/admin/content/example/event/9007199254740994/edit`. + +### Why bigint ids are strings + +A JavaScript number holds integers exactly only up to 2^53 − 1 (`9007199254740991`). A bigint key can be much larger, and a larger id read as a number is silently rounded to a different record: `Number("9007199254740993")` is `9007199254740992`. VitNode never converts a bigint id through `Number`. Drizzle reads the column in `string` mode, and every API, event, cursor and AdminCP URL carries the exact decimal string. + +Once the `example_events` id sequence is past 2^53, the public API still returns every id intact: + +```bash +curl "http://localhost:3000/api/@vitnode/example/content/events/big-launch" +``` + +```json title="Response (trimmed)" +{ + "id": "9007199254740994", + "title": "Big launch", + "slug": "big-launch", + "primaryTag": { "id": "047d2c6c-1d86-47e2-bcd3-2df9f5359541" }, + "tags": ["047d2c6c-1d86-47e2-bcd3-2df9f5359541"] +} +``` + +`primaryTag` and `tags` point at `example.tag` records, so they carry UUIDs. Keep bigint ids as strings in your own code too. + +## Valid ids + +Every route that takes an id parses it with the content type's strategy before touching the database. Anything else answers `400`, never a database error: + +| Strategy | Accepted | Refused, for example | +| -------- | ---------------------------------------------------------------------------------------- | -------------------------------------- | +| `serial` | A positive integer up to 2,147,483,647, or its digits in a URL | `abc`, `0012`, `-1` | +| `bigint` | Positive decimal digits up to 9,223,372,036,854,775,807, without a sign or leading zeros | `0012`, `-1`, `9223372036854775808` | +| `uuid` | Lowercase, hyphenated 8-4-4-4-12 hex | An uppercase UUID, `not-a-uuid`, `123` | + +Each record has exactly one spelling, so one record is never cached or indexed under two keys. + +## Where ids appear + +Every Content Engine feature works with all three strategies: the public API, the AdminCP lists, editors, pickers and edit links, [public pages](/docs/content-engine/public-pages) and sitemaps, previews, schedules, revisions, [live editing](/docs/content-engine/live-editing), events, search, pagination cursors, [duplicate](/docs/content-engine/duplicate-content) and [hide](/docs/content-engine/content-visibility). + +- **Relations** use the key type of the record they point at. A bigint event that links to uuid tags gets a `uuid` foreign key and a `bigint`-to-`uuid` junction table. See [Relations across id strategies](/docs/content-engine/relations#relations-across-id-strategies). +- **Shared core tables** that hold records of any content type, such as revisions, schedules, slug history, live editing drafts and the search index, store the id as text in a `varchar(64)` `itemId` column. See [Database](/docs/content-engine/database#ids-and-shared-tables). +- **Events** type `contentId` by strategy: `number` for serial, `string` for uuid and bigint. +- **Sitemaps** page through records in key order. For a uuid content type that order is lexical, not creation order, and every record still appears once. +- **Repeatable rows** keep their own `serial` ids, whatever the owner uses. User and file references stay integers. + +## Helpers + +Import these from `@vitnode/core/content` when your own code handles ids: + +| Helper | Returns | Use it to | +| -------------------------------------- | -------------------- | ---------------------------------------------------------------------------- | +| `ContentIdOf` | `number` or `string` | Type an id of one content type | +| `ContentId` | `number \| string` | Type an id in code that handles any content type | +| `parseContentIdOf(definition, value)` | The id, or `null` | Validate an id from a request, a job payload or a URL | +| `parseContentId(strategy, value)` | The id, or `null` | The same, when you hold only the strategy | +| `contentIdParamSchema(strategy)` | A Zod schema | Validate a path or query parameter. A serial id becomes a number | +| `contentIdSchema(strategy)` | A Zod schema | Validate an id inside a JSON body | +| `contentAnyIdSchema` | A Zod schema | Accept an id of any strategy, never rounding a bigint | +| `contentIdKey(id)` | `string` | Store an id in a text column, cache key or log. Serial ids keep their digits | +| `contentIdFromKey(strategy, key)` | The id, or `null` | Turn a stored key back into an id | +| `compareContentIds(strategy, a, b)` | `number` | Sort ids the way Postgres does | + +`CONTENT_SERIAL_MAX` (`2147483647`) and `CONTENT_BIGINT_MAX` (`"9223372036854775807"`) are exported too. A custom route that reads an id is shown in [Services](/docs/content-engine/services#hide-duplicate-and-content-ids). + +## Change the strategy later + +The strategy is part of the table's schema, and the engine does not convert existing data. Do not change `idStrategy` on a content type that has records and run `db:migrate`: the generated SQL cannot turn integer keys into UUIDs. + +To switch a live content type, write the data migration yourself: + +1. Add the new key column and fill it for every row. +2. Convert every column that references the old key: to-one relation columns in other content types, junction tables (`itemId` and `relatedItemId`), the translations table's `itemId` and repeatable child tables' `itemId`. +3. Rewrite the keys stored for that content type in the shared tables: `core_content_revisions`, `core_content_schedules`, `core_content_slug_history`, `core_search_index`, `core_content_drafts`, `core_content_field_locks`, `core_content_documents` and `core_ai_translation_sources`. +4. Swap the primary key and foreign key constraints, then change `idStrategy` and check that generating migrations again produces no further changes. + +Old URLs and API clients that stored the previous ids stop working, so plan redirects if ids appear in public URLs. Creating a new content type and copying the records into it is often simpler. diff --git a/apps/web/content/docs/content-engine/index.mdx b/apps/web/content/docs/content-engine/index.mdx index eff126206e..7f2c1a263d 100644 --- a/apps/web/content/docs/content-engine/index.mdx +++ b/apps/web/content/docs/content-engine/index.mdx @@ -7,7 +7,7 @@ icon: Boxes import { ImgDocs } from "@/components/fumadocs/img"; import articlesList from "./index-articles-list.webp"; -The Content Engine turns one TypeScript definition into structured content for your plugin. You describe the fields of an article or a category, and VitNode generates the database table, Zod validation, staff-only API routes, staff permissions and AdminCP screens. Optional blocks add drafts, a public API, pages with SEO metadata, revisions, scheduling, live editing, translations and search. +The Content Engine turns one TypeScript definition into structured content for your plugin. You describe the fields of an article or a category, and VitNode generates the database table, Zod validation, staff-only API routes, staff permissions and AdminCP screens. Optional blocks add drafts, a public API, pages with SEO metadata, revisions, scheduling, live editing, translations, search, hiding and duplication. This is the whole definition behind the example plugin's **Categories** screen: @@ -53,6 +53,8 @@ Its sibling `example.article` turns on more blocks. Same engine, more fields, a | `liveEditing` | Field locks, an autosaved shared draft and presence in the AdminCP edit form | | `localization` | A translations table and per-language fields and publishing | | `search` | Records in the site [search](/docs/content-engine/search) index | +| `visibility` | **Hide** and **Unhide** actions and a `can_hide` permission | +| `duplication` | A **Duplicate** action that copies a record into a new draft | Content types live in code and in your migrations, not in the database. There is no screen for creating a content type at runtime. @@ -71,6 +73,9 @@ Content types live in code and in your migrations, not in the database. There is | Store and render formatted text | [Rich text](/docs/content-engine/rich-text) | | Keep revisions, share previews or schedule publishing | [Editorial](/docs/content-engine/editorial) | | Let several editors work on one record at the same time | [Live editing](/docs/content-engine/live-editing) | +| Take a record off the public site without unpublishing | [Hide content](/docs/content-engine/content-visibility) | +| Let editors copy a record as a new draft | [Duplicate content](/docs/content-engine/duplicate-content) | +| Key records by UUID or bigint instead of serial integers | [Record ID strategies](/docs/content-engine/id-strategy) | | Change list columns, form sections or field components | [AdminCP](/docs/content-engine/admincp) | | Read and write content from your own API routes | [Services](/docs/content-engine/services) | | React to created, updated or published records | [Events](/docs/content-engine/events) | diff --git a/apps/web/content/docs/content-engine/live-editing-api.mdx b/apps/web/content/docs/content-engine/live-editing-api.mdx index f774319c1f..32113b1d53 100644 --- a/apps/web/content/docs/content-engine/live-editing-api.mdx +++ b/apps/web/content/docs/content-engine/live-editing-api.mdx @@ -117,6 +117,8 @@ Rich text documents are [Yjs](https://yjs.dev) documents, one per record, field | `core_content_field_locks` | Active field locks with their holder and expiry | | `core_content_documents` | The Yjs state of each rich text field, per record and language | +Each table keys the record by its id as text in a `varchar(64)` `itemId`, so live editing works with every [id strategy](/docs/content-engine/id-strategy). The socket accepts a uuid or bigint id and checks it against the content type's strategy, refusing a malformed one. + The daily `content-editorial-cleanup` [cron job](/docs/dev/cron) removes expired locks, documents untouched for 30 days, and drafts untouched for 30 days whose record has moved on since. A stale draft whose record has not changed is kept, because it is somebody's unsaved work. When an editor's last tab leaves a record, their locks go too. ## Use live state in a custom form layout diff --git a/apps/web/content/docs/content-engine/localization.mdx b/apps/web/content/docs/content-engine/localization.mdx index 2ab720ef0f..2b8bbdf45f 100644 --- a/apps/web/content/docs/content-engine/localization.mdx +++ b/apps/web/content/docs/content-engine/localization.mdx @@ -117,7 +117,7 @@ This is the blog plugin's category dialog, where `name` is `field.text({ localiz Saving the form sends the shared fields and every language you typed into in one request: `POST /localized` for a new record, `PUT /{id}/localized` for an existing one. The server writes them in one transaction. A new record must include its default language. A Polish-only edit does not touch the shared row, so it does not bump the record's version or expire the English cache. -With publication, each language has its own status. Publishing the record publishes its languages too, and you can then unpublish one language alone. A translation is public only when both the record and that translation are published. With `editorial`, each language also keeps its own revisions and preview links. +With publication, each language has its own status. Publishing the record publishes its languages too, and you can then unpublish one language alone. A translation is public only when both the record and that translation are published, and the record is not [hidden](/docs/content-engine/content-visibility). With `editorial`, each language also keeps its own revisions and preview links. The example plugin registers `example.localized-article` on the API only, so diff --git a/apps/web/content/docs/content-engine/meta.json b/apps/web/content/docs/content-engine/meta.json index 9faf979039..950813ba38 100644 --- a/apps/web/content/docs/content-engine/meta.json +++ b/apps/web/content/docs/content-engine/meta.json @@ -17,9 +17,12 @@ "relations", "localization", "rich-text", + "id-strategy", "---Editorial---", "editorial", "live-editing", + "content-visibility", + "duplicate-content", "---Customize---", "admincp", "services", diff --git a/apps/web/content/docs/content-engine/production.mdx b/apps/web/content/docs/content-engine/production.mdx index 271f6fec13..759a268d26 100644 --- a/apps/web/content/docs/content-engine/production.mdx +++ b/apps/web/content/docs/content-engine/production.mdx @@ -36,7 +36,7 @@ publicApi: { ## Grant the right permissions -- [ ] **Give staff only what they need** in **AdminCP → Staff**. Each content type gets `can_view`, `can_create`, `can_edit` and `can_delete`, plus `can_publish` with `publication` and `can_restore` with `editorial`. +- [ ] **Give staff only what they need** in **AdminCP → Staff**. Each content type gets `can_view`, `can_create`, `can_edit` and `can_delete`, plus `can_publish` with `publication`, `can_restore` with `editorial` and `can_hide` with [`visibility`](/docs/content-engine/content-visibility). - [ ] **Guard your own admin routes.** Generated routes check their permissions themselves. A custom route next to them needs its own `adminStaffPermission`, as shown in [Services](/docs/content-engine/services#example-3-a-feature-this-article-button). ## Ship the migration diff --git a/apps/web/content/docs/content-engine/public-api.mdx b/apps/web/content/docs/content-engine/public-api.mdx index 1efadaa73e..cf4d0ae927 100644 --- a/apps/web/content/docs/content-engine/public-api.mdx +++ b/apps/web/content/docs/content-engine/public-api.mdx @@ -251,6 +251,6 @@ All routes are `GET` and live under `/api/{pluginId}/content/{publicApi.path}`. | `/delivery/item/{id}` | Canonical URL and SEO metadata for one record, only with delivery | | `/delivery/sitemap` | Sitemap entries, only with `delivery.sitemap` | -The list accepts `first` or `last` (at most 50, also the default), `cursor`, `page`, `search` over `searchableFields`, `orderBy`, `order` and one parameter per filterable field. A record is public only when its `status` is `published` and its `publishedAt` is set and not later than the database clock. Every `publicApi` option is in the [content type reference](/docs/content-engine/reference#publicapi). +The list accepts `first` or `last` (at most 50, also the default), `cursor`, `page`, `search` over `searchableFields`, `orderBy`, `order` and one parameter per filterable field. A record is public only when its `status` is `published`, its `publishedAt` is set and not later than the database clock, and it is not [hidden](/docs/content-engine/content-visibility). Every `publicApi` option is in the [content type reference](/docs/content-engine/reference#publicapi). Next, give each article a web page with [Public pages](/docs/content-engine/public-pages), or see how these reads are cached in [Caching](/docs/content-engine/caching). diff --git a/apps/web/content/docs/content-engine/public-pages.mdx b/apps/web/content/docs/content-engine/public-pages.mdx index a1af3387d3..4ab984be1c 100644 --- a/apps/web/content/docs/content-engine/public-pages.mdx +++ b/apps/web/content/docs/content-engine/public-pages.mdx @@ -235,7 +235,7 @@ curl http://localhost:3000/api/@vitnode/example/content/articles/delivery/resolv ## Keep old URLs working -Renaming a slug breaks links people already shared. With `delivery.redirects`, the old slug is stored in `core_content_slug_history` when an editor changes it, and the resolve route answers `{ "type": "redirect", "status": 308, "location": "/articles/new-slug" }`. `contentDeliveryPage` turns that into a `308` redirect. +Renaming a slug breaks links people already shared. With `delivery.redirects`, the old slug is stored in `core_content_slug_history` when an editor changes it, and the resolve route answers `{ "type": "redirect", "status": 308, "location": "/articles/new-slug" }`. `contentDeliveryPage` turns that into a `308` redirect. While a record is [hidden](/docs/content-engine/content-visibility), its page and its old slugs answer `404` instead. ## Sitemap entries diff --git a/apps/web/content/docs/content-engine/reference.mdx b/apps/web/content/docs/content-engine/reference.mdx index 41a05f1b72..e3f314989a 100644 --- a/apps/web/content/docs/content-engine/reference.mdx +++ b/apps/web/content/docs/content-engine/reference.mdx @@ -50,40 +50,43 @@ Many of these rules are type errors too, so your editor often complains before t ## Top-level options -| Name | Type | Required | Default | Description | -| -------------- | ----------------------------------- | -------- | ------- | ------------------------------------------------------------------------------- | -| `id` | `string` | Yes | | `plugin.entity`, such as `example.article`. See [Names](#names) | -| `tableName` | `string` | Yes | | Postgres table name, such as `example_articles` | -| `fields` | `Record` | Yes | | At least one field built with a [`field.*` helper](/docs/content-engine/fields) | -| `admin` | [`admin`](#admin) | No | `{}` | AdminCP path, list, form and title | -| `publication` | `boolean` | No | off | Draft and published states | -| `publicApi` | [`publicApi`](#publicapi) | No | off | Read-only public routes | -| `delivery` | `true` \| [`delivery`](#delivery) | No | off | Public page URLs, SEO metadata, redirects and sitemap | -| `editorial` | `true` \| [`editorial`](#editorial) | No | off | Revisions, preview links and scheduling | -| `liveEditing` | `boolean` | No | `false` | [Live editing](#liveediting) in the AdminCP. Needs `editorial` | -| `localization` | [`localization`](#localization) | No | off | A translation table for `localized: true` fields | -| `search` | [`search`](#search) | No | off | Site search indexing | -| `indexes` | [`indexes`](#indexes) | No | `[]` | Extra database indexes | - -A feature block is on when it is present. `publicApi`, `localization` and `search` take an options object. `publication` and `liveEditing` are `true`, and `editorial` and `delivery` take `true` or an options object. Leave a block out, or set it to `false`, to keep it off. Write `true` or the object directly rather than a `boolean` variable, because the generated types read the literal to decide which columns and routes exist. +| Name | Type | Required | Default | Description | +| -------------- | --------------------------------------- | -------- | ---------- | ------------------------------------------------------------------------------- | +| `id` | `string` | Yes | | `plugin.entity`, such as `example.article`. See [Names](#names) | +| `tableName` | `string` | Yes | | Postgres table name, such as `example_articles` | +| `fields` | `Record` | Yes | | At least one field built with a [`field.*` helper](/docs/content-engine/fields) | +| `idStrategy` | `"serial"` \| `"uuid"` \| `"bigint"` | No | `"serial"` | Type of the primary key. See [`idStrategy`](#idstrategy) | +| `admin` | [`admin`](#admin) | No | `{}` | AdminCP path, list, form and title | +| `publication` | `boolean` | No | off | Draft and published states | +| `publicApi` | [`publicApi`](#publicapi) | No | off | Read-only public routes | +| `delivery` | `true` \| [`delivery`](#delivery) | No | off | Public page URLs, SEO metadata, redirects and sitemap | +| `editorial` | `true` \| [`editorial`](#editorial) | No | off | Revisions, preview links and scheduling | +| `liveEditing` | `boolean` | No | `false` | [Live editing](#liveediting) in the AdminCP. Needs `editorial` | +| `localization` | [`localization`](#localization) | No | off | A translation table for `localized: true` fields | +| `search` | [`search`](#search) | No | off | Site search indexing | +| `visibility` | `boolean` | No | off | [Hide and unhide](#visibility) records. Needs `publication` | +| `duplication` | `true` \| [`duplication`](#duplication) | No | off | A [Duplicate](#duplication) action that copies a record into a new draft | +| `indexes` | [`indexes`](#indexes) | No | `[]` | Extra database indexes | + +A feature block is on when it is present. `publicApi`, `localization` and `search` take an options object. `publication`, `liveEditing` and `visibility` are `true`, and `editorial`, `delivery` and `duplication` take `true` or an options object. Leave a block out, or set it to `false`, to keep it off. Write `true` or the object directly rather than a `boolean` variable, because the generated types read the literal to decide which columns and routes exist. ### Names -| Name | Rule | -| --------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `id` | Matches `/^[a-z0-9]+(?:\.[a-z0-9-]+)+$/`: lowercase, at least two dot-separated segments, dashes allowed after the first. At most 100 characters with `editorial` or `search` | -| `tableName` | snake_case, starts with a letter, at most 63 characters | -| Field names | camelCase, start with a lowercase letter | -| Reserved fields | `id`, `createdAt` and `updatedAt` always. `status` and `publishedAt` with `publication`. `version` with `editorial` | -| Reserved for the list route | `cursor`, `first`, `last`, `order`, `orderBy` and `search`, because they are query parameters of the generated list route. Checked at plugin registration | -| Localized fields | Cannot be named `itemId`, `languageId`, `version`, `createdAt` or `updatedAt` (translation table columns). A localized type cannot expose a public field called `locale` | +| Name | Rule | +| --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `id` | Matches `/^[a-z0-9]+(?:\.[a-z0-9-]+)+$/`: lowercase, at least two dot-separated segments, dashes allowed after the first. At most 100 characters with `editorial` or `search` | +| `tableName` | snake_case, starts with a letter, at most 63 characters | +| Field names | camelCase, start with a lowercase letter | +| Reserved fields | `id`, `createdAt` and `updatedAt` always. `status` and `publishedAt` with `publication`. `version` with `editorial`. `hiddenAt`, `hiddenBy` and `visibility` with `visibility` | +| Reserved for the list route | `cursor`, `first`, `last`, `order`, `orderBy` and `search`, because they are query parameters of the generated list route. Checked at plugin registration | +| Localized fields | Cannot be named `itemId`, `languageId`, `version`, `createdAt` or `updatedAt` (translation table columns). A localized type cannot expose a public field called `locale` | Two names are derived from the id: - The **entity key** is the id without its first segment, with further dots turned into `_`. `example.article` becomes `article`. Strings live under `{pluginId}.content.{entityKey}`, for example `@vitnode/example.content.article`. - The **permission module** is the entity key with anything outside `a-z0-9` turned into `_`, so `example.localized-article` becomes `localized_article`. Permissions are named `@vitnode/example:article:can_view`. Override it with `admin.permissionModule`. -Every content type gets `can_view`, `can_create`, `can_edit` and `can_delete`. `publication` adds `can_publish` and `editorial` adds `can_restore`. +Every content type gets `can_view`, `can_create`, `can_edit` and `can_delete`. `publication` adds `can_publish`, `editorial` adds `can_restore` and `visibility` adds `can_hide`. Duplicating needs `can_create` and `can_view`, so `duplication` adds no permission. ## `admin` @@ -264,6 +267,43 @@ search: { }, ``` +## `visibility` + +`visibility: true` adds the nullable `hiddenAt` and `hiddenBy` columns, the `can_hide` permission, the `POST /{id}/hide` and `POST /{id}/unhide` staff routes and a **Visibility** filter in the AdminCP list. A hidden record is left out of every public read while it keeps its `status` and `publishedAt`. It has no other options. Guide: [Hide content](/docs/content-engine/content-visibility). + +```ts title="plugins/blog/src/content/post.ts" +publication: true, +visibility: true, +``` + +## `duplication` + +Adds a **Duplicate** action and the `POST /{id}/duplicate` staff route, which copy a record and its translations into a new draft. Pass `true`, or an object to change the title suffix. Guide: [Duplicate content](/docs/content-engine/duplicate-content). + +| Name | Type | Default | Description | +| ------------- | --------- | ---------------------------------------------- | --------------------------------------------------------------------------------------- | +| `titleSuffix` | `boolean` | On when `admin.titleField` is a `field.text()` | Adds a localized "(Copy)" to the copy's title. `true` with any other title field throws | + +```ts title="plugins/blog/src/content/post.ts" +duplication: true, +``` + +## `idStrategy` + +The type of the primary key. Every foreign key, junction column and translation `itemId` that points at the record takes the same type. Guide: [Record ID strategies](/docs/content-engine/id-strategy). + +| Value | Postgres key | In JSON | +| -------------------- | ----------------------------------------- | ------------------ | +| `"serial"` (default) | `serial` | A number | +| `"uuid"` | `uuid`, `DEFAULT gen_random_uuid()` | A lowercase string | +| `"bigint"` | `bigint GENERATED BY DEFAULT AS IDENTITY` | A decimal string | + +Any other value throws. Changing the strategy of a content type that already has records needs a [hand-written data migration](/docs/content-engine/id-strategy#change-the-strategy-later). + +```ts title="plugins/example/src/content/event.ts" +idStrategy: "bigint", +``` + ## `indexes` Extra database indexes on the base table. The engine already indexes slugs, `unique: true` text fields, foreign keys, `createdAt`, `updatedAt` and `(status, publishedAt)`. A declared index on the same columns replaces the generated one instead of adding a second. Guide: [Database and migrations](/docs/content-engine/database). @@ -313,10 +353,13 @@ Each row is a rule `defineContentType` enforces, with the start of the message i | `delivery.seo.fallbackDescriptionField` | `delivery.seo.descriptionField` | ``delivery.seo.fallbackDescriptionField is set without `descriptionField`.`` | | `editorial.preview` | `publicApi` | ``editorial.preview needs `publicApi: { path, fields }`.`` | | `editorial.scheduling` | `publication` | ``editorial.scheduling needs `publication: true`.`` | +| `visibility` | `publication` | ``visibility needs `publication: true`.`` | +| `duplication.titleSuffix: true` | `admin.titleField` pointing at a `field.text()` | ``duplication.titleSuffix needs `admin.titleField` to be a text field`` | | A `localized: true` field | `localization` | ``Field "title" is `localized: true` but the content type has no `localization: { defaultLocale }` block`` | | `localization` | At least one `localized: true` field | ``localization is enabled but no field is marked `localized: true`,`` | | Any block with an `enabled` key | Nothing: a block is on when present | `publicApi.enabled is not an option.` | | `publication` or `liveEditing` as an object | `true` or `false` | ``publication is `true` or `false`.`` | +| `visibility` as an object | `true` or `false` | ``visibility is `true` or `false`.`` | | `admin.list.thumbnailField` | `admin.titleField` in `admin.list.columns` | `admin.list.thumbnailField is drawn in the title column, but admin.titleField` | | `admin.form.sections` | No `admin.form.fields` | ``admin.form declares both `fields` and `sections`.`` | | `admin.list.defaultOrderBy` | A system column or an `admin.list.orderableFields` entry | `admin.list.defaultOrderBy is "x", which is not in admin.list.orderableFields.` | diff --git a/apps/web/content/docs/content-engine/relations.mdx b/apps/web/content/docs/content-engine/relations.mdx index ca19a5a015..7c92b07555 100644 --- a/apps/web/content/docs/content-engine/relations.mdx +++ b/apps/web/content/docs/content-engine/relations.mdx @@ -162,6 +162,41 @@ The generated migration creates one table per junction and repeatable field. +## Relations across id strategies + +A relation can point at a content type with a different [`idStrategy`](/docs/content-engine/id-strategy). The foreign key always takes the type of the key it references. The example plugin's `example.event` uses `bigint` and links to `uuid` tags and `serial` categories: + +```ts title="plugins/example/src/content/event.ts" +import type { ContentRelationTarget } from "@vitnode/core/content"; + +import { categoryContentType } from "./category"; +import { tagContentType } from "./tag"; + +fields: { + primaryTag: field.relation({ + nullable: true, + onDelete: "set null", + target: (): ContentRelationTarget<"uuid"> => tagContentType, + }), + category: field.relation({ + nullable: true, + onDelete: "set null", + target: () => categoryContentType, + }), + tags: field.relation({ multiple: true, ordered: true, target: () => tagContentType }), + relatedEvents: field.relation({ multiple: true, onDelete: "cascade", self: true }), +}, +``` + +| Field | Column or junction table | +| --------------- | ------------------------------------------------------------------- | +| `primaryTag` | `uuid` column referencing `example_tags.id` | +| `category` | `integer` column referencing `example_categories.id` | +| `tags` | `example_events_tags` with `itemId bigint` and `relatedItemId uuid` | +| `relatedEvents` | `example_events_related_events` with both columns `bigint` | + +`field.relation()` reads the target's strategy from `target`, so the API takes and returns tag ids as strings and category ids as numbers. When two content type modules import each other, TypeScript cannot infer a type through the cycle and falls back to serial. Annotate the thunk with `ContentRelationTarget<"uuid">` or `ContentRelationTarget<"bigint">`, as `primaryTag` does. At runtime, a `references` entry whose column type disagrees with the target's strategy throws and names the field. + ## Write related values A relation takes ids. A to-many relation and a repeatable take the whole list, which replaces what the record had: diff --git a/apps/web/content/docs/content-engine/search.mdx b/apps/web/content/docs/content-engine/search.mdx index 7caf0b1385..93abc81e53 100644 --- a/apps/web/content/docs/content-engine/search.mdx +++ b/apps/web/content/docs/content-engine/search.mdx @@ -83,14 +83,14 @@ Both buttons need the **Rebuild and clear the search index** staff permission (` ## What gets indexed and when -Every record is in the index, but only public ones show up on `/search`. A draft is stored as a private document without a URL. With `authorField`, it appears on its author's own timeline and on the AdminCP user page, but never in public results. - -| Write | Search index | -| ------------------------------------------------------------------------ | ----------------------------- | -| Create, edit, publish, unpublish, restore or delete in the AdminCP | Updated right after the write | -| A scheduled publish or unpublish | Updated when the job runs | -| Your own route using the editorial service and `contentEditorialEffects` | Updated | -| The plain `service` from `model.service(c)` | Not updated | +Every record is in the index, but only public ones show up on `/search`. A draft is stored as a private document without a URL. A [hidden](/docs/content-engine/content-visibility) record's document turns private the moment it is hidden, and public again when it is unhidden. With `authorField`, it appears on its author's own timeline and on the AdminCP user page, but never in public results. + +| Write | Search index | +| ------------------------------------------------------------------------------------------- | ----------------------------- | +| Create, edit, publish, unpublish, restore, hide, unhide, duplicate or delete in the AdminCP | Updated right after the write | +| A scheduled publish or unpublish | Updated when the job runs | +| Your own route using the editorial service and `contentEditorialEffects` | Updated | +| The plain `service` from `model.service(c)` | Not updated | An edit rewrites the document only when an indexed field changed: the title, description, content fields, slug or author. Unpublishing keeps the document but makes it private, so the record drops out of `/search` at once. Deleting the record removes its document. diff --git a/apps/web/content/docs/content-engine/seo.mdx b/apps/web/content/docs/content-engine/seo.mdx index bc933238df..0a60b3fdd2 100644 --- a/apps/web/content/docs/content-engine/seo.mdx +++ b/apps/web/content/docs/content-engine/seo.mdx @@ -152,7 +152,8 @@ curl "http://localhost:3000/api/@vitnode/example/content/articles/delivery/sitem - A page holds 1,000 entries by default and up to 50,000 with `?limit=`. `cursor` is the `itemId` to continue after, so pass `nextCursor` back as `?cursor=` until it is `null`. - `lastModified` is the record's `updatedAt`, or the translation's when that is newer. -- Records whose `noIndexField` is `true` are left out. +- Records whose `noIndexField` is `true` are left out, and so are [hidden](/docs/content-engine/content-visibility) records. +- Entries come in id order. For a [uuid](/docs/content-engine/id-strategy) content type that order is lexical, not creation order, and every record still appears once. - A localized type returns one language per request. Ask for another with `?locale=`: ```bash diff --git a/apps/web/content/docs/content-engine/services.mdx b/apps/web/content/docs/content-engine/services.mdx index 76fbb45355..208bf74277 100644 --- a/apps/web/content/docs/content-engine/services.mdx +++ b/apps/web/content/docs/content-engine/services.mdx @@ -326,22 +326,109 @@ If someone saved the article after version 3 was loaded, the answer is a `409`: Load the article again, take the new version, and retry. A job without a signed-in user passes `CONTENT_SYSTEM_ACTOR` instead of `resolveContentActor(c)`. +## Hide, duplicate and content IDs + +A content type with [`visibility`](/docs/content-engine/content-visibility) or [`duplication`](/docs/content-engine/duplicate-content) adds methods to `service` and `editorialService`. They exist in the types only when the option is on, so calling `hide` on a content type without visibility is a compile error. + +| Method | `service` options | `editorialService` options | +| ------------------------------------------ | ------------------------------------ | ------------------------------------------- | +| `hide(id, options)`, `unhide(id, options)` | `{ actorUserId?, tx? }` | `{ actor, expectedVersion?, tx? }` | +| `duplicate(sourceId, options)` | `{ overrides?, translations?, tx? }` | `{ actor, overrides?, translations?, tx? }` | + +Hiding a record that is already hidden returns `changed: false` and writes nothing. The editorial `hide` and `unhide` raise the version and store a revision, and the editorial `duplicate` stores a first revision for the copy and for each copied translation. All of them return `null` when the record does not exist. + +Like the other service methods, these emit no events. Run the effects after the transaction commits, as the generated routes do: + +```ts +import { contentVisibilityEffects, resolveContentActor } from "@vitnode/core/content/server"; + +const { editorialService } = postContent; +if (!editorialService) throw new Error("Articles have no editorial."); + +const pluginId = CONFIG_PLUGIN.pluginId; +const outcome = await editorialService(c, { pluginId }).hide(703, { + actor: resolveContentActor(c), +}); + +if (outcome) { + await contentVisibilityEffects(c, postContent, outcome, { pluginId }); +} +``` + +`contentVisibilityEffects` emits `hidden` or `unhidden`, updates the search index and the delivery data, and [notifies front ends](/docs/content-engine/caching#notify-a-separate-front-end). It does nothing for an outcome with `changed: false`. For a copy, call `contentDuplicateEffects`, shown in [Duplicate content](/docs/content-engine/duplicate-content#duplicate-from-your-own-code). + +### Content IDs + +Service methods take and return ids typed by the content type's [`idStrategy`](/docs/content-engine/id-strategy): `ContentIdOf` is `number` for a serial type and `string` for a uuid or bigint one. Never pass an id through `Number()` in code that may meet a bigint type. + +A custom route reads an id from the URL as text. Parse it with the content type's strategy before calling a service, so a malformed id answers `400` instead of reaching the database. The example plugin's events use bigint ids: + +```ts title="plugins/example/src/api/modules/events/title.route.ts" +import { buildRoute } from "@vitnode/core/api/lib/route"; +import { parseContentIdOf } from "@vitnode/core/content"; +import { HTTPException } from "hono/http-exception"; +import { z } from "zod"; + +import { CONFIG_PLUGIN } from "@/const"; +import { eventContentType } from "@/content/event"; +import { eventContent } from "@/database/events"; + +export const eventTitleRoute = buildRoute({ + pluginId: CONFIG_PLUGIN.pluginId, + route: { + method: "get", + path: "/{id}/title", + description: "The title of one published event", + responses: { + 200: { + content: { + "application/json": { + schema: z.object({ id: z.string(), title: z.string() }), + }, + }, + description: "The event title", + }, + 400: { description: "Invalid event id" }, + 404: { description: "Event not found" }, + }, + }, + handler: async c => { + const id = parseContentIdOf(eventContentType, c.req.param("id")); + if (id === null) { + throw new HTTPException(400, { message: "Invalid event id." }); + } + + const { publicService } = eventContent; + if (!publicService) throw new Error("Events have no public API."); + + const event = await publicService(c).findById(id); + if (!event) throw new HTTPException(404, { message: "Event not found." }); + + return c.json({ id, title: event.title }, 200); + }, +}); +``` + +`id` is a `string` here, because `example.event` uses bigint. For a route that validates its parameters with Zod, `contentIdParamSchema(eventContentType.idStrategy)` accepts exactly what `parseContentIdOf` accepts. Code that handles content types it does not know statically uses `ContentId` (`number | string`) and `contentAnyIdSchema`. Every helper is listed in [Record ID strategies](/docs/content-engine/id-strategy#helpers). + ## Reference ### Service methods -`publicService` only reads: `findMany({ filters, orderBy, query })`, `findById(id)` and `findBySlug(slug)`, each limited to published records and public fields. `editorialService` only writes: `create`, `update`, `delete`, `publish`, `unpublish`, `restore`, plus `relations` and `repeatable`. `service` has everything below: - -| Method | Returns | -| ---------------------------------------------- | -------------------------------------------------------------------------------- | -| `findMany({ filters, orderBy, query, where })` | `{ edges, pageInfo }`, drafts included. `where` takes a Drizzle `SQL` condition | -| `findById(id)` | The row, or `null` | -| `findRowById(id)` | The row with display labels for relations and users, or `null` | -| `findDetail(id)` | The row with relation, repeatable and file values, or `null` | -| `create(values)` | The new row | -| `update(id, values)` | `{ row, changedFields }`, or `null` when the id does not exist | -| `delete(id)` | The deleted row, or `null` | -| `publish(id)`, `unpublish(id)` | `{ changed, publishedAt, row }`, or `null`. Only with `publication` | +`publicService` only reads: `findMany({ filters, orderBy, query })`, `findById(id)` and `findBySlug(slug)`, each limited to published records and public fields. `editorialService` only writes: `create`, `update`, `delete`, `publish`, `unpublish`, `restore`, plus `relations` and `repeatable`, and `hide`, `unhide` and `duplicate` when their option is on. `service` has everything below: + +| Method | Returns | +| ---------------------------------------------- | ------------------------------------------------------------------------------------- | +| `findMany({ filters, orderBy, query, where })` | `{ edges, pageInfo }`, drafts included. `where` takes a Drizzle `SQL` condition | +| `findById(id)` | The row, or `null` | +| `findRowById(id)` | The row with display labels for relations and users, or `null` | +| `findDetail(id)` | The row with relation, repeatable and file values, or `null` | +| `create(values)` | The new row | +| `update(id, values)` | `{ row, changedFields }`, or `null` when the id does not exist | +| `delete(id)` | The deleted row, or `null` | +| `publish(id)`, `unpublish(id)` | `{ changed, publishedAt, row }`, or `null`. Only with `publication` | +| `hide(id)`, `unhide(id)` | `{ changed, hiddenAt, hiddenBy, row }`, or `null`. Only with `visibility` | +| `duplicate(sourceId)` | `{ row, sourceId, translations, skippedLocales }`, or `null`. Only with `duplication` | | `advanced(id)`, `advancedFields(id, fields)` | Values of to-many relations, repeatables and galleries | | `options(field, search?, ids?)` | `{ value, label, color? }[]` for a relation or user picker | | `relations.` | `add`, `remove`, `set`, `reorder`, `get` for a to-many relation | diff --git a/apps/web/content/docs/dev/events/built-in-events.mdx b/apps/web/content/docs/dev/events/built-in-events.mdx index e1796918ab..ccd7e496ef 100644 --- a/apps/web/content/docs/dev/events/built-in-events.mdx +++ b/apps/web/content/docs/dev/events/built-in-events.mdx @@ -624,9 +624,14 @@ its id: `content..`. For the example plugin's `example.article` that means `content.example.article.created`, and for the blog's `blog.post` it means `content.blog.post.created`. +Every payload's `contentId` (and `sourceId` and `duplicatedFromId`) has the +content type's [id type](/docs/content-engine/id-strategy): a `number` for the +default `serial` strategy, a `string` for `uuid` and `bigint`. A bigint id +arrives as its exact decimal string, so never pass it through `Number()`. + Which actions exist depends on what the definition opts into. A content type that declares nothing beyond its fields emits three events; one that declares -everything emits sixteen. +everything emits nineteen. | The definition declares | Events it adds | | ------------------------------------ | ------------------------------------------------------------------- | @@ -638,6 +643,8 @@ everything emits sixteen. | `localization` **and** `publication` | `translation_published`, `translation_unpublished` | | `localization` **and** `editorial` | `translation_restored` | | `delivery` | `delivery_slug_changed`, `delivery_redirect_created` | +| `duplication` | `duplicated` | +| `visibility` | `hidden`, `unhidden` | The gating is in the types, not just at runtime: `ContentEventsFor` expands to literal keys only for the features the definition enables. The example plugin's @@ -658,6 +665,9 @@ the map and a listener for it does not compile. | `restored` | `{ contentId, changedFields, version, revisionId, restoredFromRevisionId }` | A record is rolled back to an earlier revision - emitted **instead of** `updated` | | `scheduled` | `{ contentId, action, actorUserId, scheduledFor, scheduleId }` | A publish or unpublish is booked for later | | `schedule_cancelled` | `{ contentId, action, actorUserId, scheduleId }` | A pending booking is called off | +| `duplicated` | `{ actorUserId, contentId, contentTypeId, sourceId }` | A record was copied into a new draft - after the copy's own `created` | +| `hidden` | `{ actorUserId, contentId, hiddenAt }` | A visible record is taken off the public site without being unpublished | +| `unhidden` | `{ actorUserId, contentId }` | A hidden record is made visible again | @@ -666,13 +676,18 @@ the map and a listener for it does not compile. type={{ contentId: { description: 'Id of the created, updated or deleted row.', - type: 'number', + type: 'ContentIdOf', }, changedFields: { description: "Update only - the fields whose value actually changed, narrowed to this content type's own field names. An update that changes nothing emits no event at all.", type: 'ContentFieldName[]', }, + duplicatedFromId: { + description: + 'Created only, on a content type with `duplication` - the record this one was copied from. Absent for an ordinary create, so a listener can tell copies apart without waiting for `duplicated`.', + type: 'ContentIdOf', + }, }} /> @@ -692,7 +707,7 @@ carries neither. type={{ contentId: { description: 'Id of the record whose visibility moved.', - type: 'number', + type: 'ContentIdOf', }, publishedAt: { description: @@ -729,7 +744,7 @@ than a rewrite - which is why a restore emits one event and not both. type={{ contentId: { description: 'Id of the restored record.', - type: 'number', + type: 'ContentIdOf', }, changedFields: { description: @@ -753,8 +768,8 @@ than a rewrite - which is why a restore emits one event and not both. /> **A listener would** do exactly what its `updated` listener does. A restore -never moves `status` or `publishedAt`, so nothing watching visibility has to -handle it. +never moves `status`, `publishedAt` or `hiddenAt`, so nothing watching +visibility has to handle it. @@ -768,7 +783,7 @@ with `scheduleId` and `scheduledBy` set. type={{ contentId: { description: 'Id of the record the booking is about.', - type: 'number', + type: 'ContentIdOf', }, action: { description: 'Which transition was booked, or cancelled.', @@ -794,6 +809,69 @@ with `scheduleId` and `scheduledBy` set. **A listener would** show an editorial calendar, warn an author that two bookings collide, or pre-warm a render shortly before `scheduledFor`. + + + +A [duplicate](/docs/content-engine/duplicate-content) first emits the copy's +ordinary `created` (with `duplicatedFromId`) and one `translation_created` per +copied language, then `duplicated`. Indexing already ran for `created`, so +`duplicated` is a notification only. + +', + }, + contentTypeId: { + description: 'The content type id, such as `blog.post`.', + type: 'string', + }, + sourceId: { + description: + 'Id of the record that was copied. It is only read, never changed.', + type: 'ContentIdOf', + }, + }} +/> + +**A listener would** credit the original author in an audit log, or link the +copy to its source in a plugin-owned table. + + + + +[Hiding](/docs/content-engine/content-visibility) takes a record off the public +site without touching `status` or `publishedAt`, so a hidden record emits +neither `unpublished` nor, when unhidden, `published`. Hiding a record that is +already hidden emits nothing. + +', + }, + hiddenAt: { + description: 'Hidden only - when the record was hidden.', + type: 'Date', + }, + }} +/> + +**A listener would** purge a CDN entry, pull the record from an external +search engine, or tell the author that a moderator took their article down. +Hiding a draft emits the event too, though nothing public changed. + @@ -821,7 +899,7 @@ has to go and ask which language moved. contentId: { description: 'Id of the record the translation belongs to - the base row, not the translation row.', - type: 'number', + type: 'ContentIdOf', }, locale: { description: @@ -897,7 +975,7 @@ different facts with different audiences. type={{ contentId: { description: 'Id of the record whose URL moved.', - type: 'number', + type: 'ContentIdOf', }, slug: { description: 'Slug changed only - the slug the record answers to now.', @@ -1045,7 +1123,9 @@ inspects `failures` and retries the whole delivery when it is non-empty. publication transitions, per-locale translation events and slug history. The six names below are re-emitted from those by listeners registered on the plugin's own `admin` module, so existing consumers keep working. Prefer the - `content.*` ones for anything new. + `content.*` ones for anything new. A duplicated article re-emits + `blog.post.created` once; `duplicated`, `hidden` and `unhidden` have no blog + adapter. | Event | Payload | Re-emitted from | diff --git a/apps/web/content/docs/dev/search-elasticsearch.mdx b/apps/web/content/docs/dev/search-elasticsearch.mdx index 250dd309c8..7df73dc9dd 100644 --- a/apps/web/content/docs/dev/search-elasticsearch.mdx +++ b/apps/web/content/docs/dev/search-elasticsearch.mdx @@ -86,6 +86,14 @@ collection) needs the **Rebuild and clear the search index** permission mapping. + + Documents now carry an `itemKey` keyword with the record id as text, so + [uuid and bigint](/docs/content-engine/id-strategy) records can be found. + VitNode adds the field to an existing index the first time it uses it. Older + documents get a value on the next **Rebuild index**; until then, results fall + back to the numeric `itemId`, which works for serial ids only. + + Do not delete `core_search_index`. It remains VitNode's source of truth and lets you switch providers without losing your map back home. diff --git a/apps/web/content/docs/dev/search.mdx b/apps/web/content/docs/dev/search.mdx index 1e222b31f9..237f045c99 100644 --- a/apps/web/content/docs/dev/search.mdx +++ b/apps/web/content/docs/dev/search.mdx @@ -208,9 +208,10 @@ A rebuild is a queued job, so it only runs once [cron](/docs/dev/cron) is runnin type: 'string', }, itemId: { - description: 'Primary key of the indexed record.', + description: + 'Primary key of the indexed record. A number for serial ids, a string for Content Engine types with a uuid or bigint idStrategy. Stored as text in core_search_index.', required: true, - type: 'number', + type: 'number | string', }, title: { description: 'Document title (indexed with highest search relevance).', diff --git a/apps/web/content/docs/guides/blog.mdx b/apps/web/content/docs/guides/blog.mdx index ab755c82e5..53b5cd2f6d 100644 --- a/apps/web/content/docs/guides/blog.mdx +++ b/apps/web/content/docs/guides/blog.mdx @@ -140,6 +140,16 @@ text, or **Open** a language that is missing a translation. The **Draft / Published** switch sits next to **Save changes**. Switching asks for confirmation first, because it changes what readers see immediately. +### Hide or duplicate an article + +The **More actions** menu of an article in the list has **Hide** and +**Duplicate**. Hiding takes a published article off the site, search and the +sitemap without unpublishing it, and **Unhide** brings it back with the same +URL and date. It needs the **Hide and unhide articles** permission. Duplicating +makes a draft copy with every translation, such as "Hello world (Copy)". See +[Hide content](/docs/content-engine/content-visibility) and +[Duplicate content](/docs/content-engine/duplicate-content). + ### Translate side by side Pick a language from **Translate**. Each translated field then shows the default From 11e05b6747f6eee92329f5c2cdf8da2a101266ac Mon Sep 17 00:00:00 2001 From: aXenDeveloper Date: Sun, 11 Oct 2026 14:19:59 +0200 Subject: [PATCH 15/17] =?UTF-8?q?feat(components):=20=E2=9C=A8=20add=20Hid?= =?UTF-8?q?denIcon=20component=20for=20visibility=20control=20feat(server)?= =?UTF-8?q?:=20=E2=9C=A8=20implement=20hidden=20access=20control=20for=20c?= =?UTF-8?q?ontent=20visibility=20feat(admin):=20=E2=9C=A8=20create=20publi?= =?UTF-8?q?cation=20label=20component=20for=20content=20status=20display?= =?UTF-8?q?=20feat(admin):=20=E2=9C=A8=20add=20save=20menu=20for=20content?= =?UTF-8?q?=20actions=20including=20publication=20and=20visibility?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../migration.sql | 1 + .../snapshot.json | 17009 +++++++++++++++ .../migration.sql | 3 + .../snapshot.json | 17060 ++++++++++++++++ .../content/docs/content-engine/admincp.mdx | 4 +- .../content-engine/content-visibility.mdx | 25 +- .../docs/content-engine/live-editing-api.mdx | 2 +- .../docs/content-engine/live-editing.mdx | 6 +- .../content/docs/content-engine/reference.mdx | 1 + .../content/docs/content-engine/search.mdx | 47 + apps/web/content/docs/dev/search.mdx | 27 +- apps/web/content/docs/guides/blog.mdx | 9 +- apps/web/src/locales/@vitnode/blog/pl.json | 7 +- apps/web/src/locales/@vitnode/core/pl.json | 2 + packages/elasticsearch/src/index.ts | 51 +- .../src/api/adapters/search/postgres.ts | 39 +- .../api/lib/response-cache-headers.test.ts | 1 + packages/vitnode/src/api/models/search.ts | 118 +- .../api/modules/search/routes/search.route.ts | 31 +- .../modules/search/routes/timeline.route.ts | 12 +- .../vitnode/src/components/hidden-icon.tsx | 28 + .../vitnode/src/content/admin/spec.test.ts | 8 +- packages/vitnode/src/content/admin/spec.ts | 2 + packages/vitnode/src/content/define-search.ts | 42 + packages/vitnode/src/content/public.test-d.ts | 4 +- packages/vitnode/src/content/search.test.ts | 2 + packages/vitnode/src/content/search.ts | 1 + .../src/content/server/delivery-routes.ts | 8 +- .../src/content/server/delivery-service.ts | 29 +- .../src/content/server/hidden-access.ts | 146 + packages/vitnode/src/content/server/index.ts | 1 + .../server/localized-public-service.ts | 17 +- .../src/content/server/public-routes.ts | 26 +- .../src/content/server/public-service.ts | 24 +- .../vitnode/src/content/server/publication.ts | 16 + .../content/server/search-document.test.ts | 1 + .../src/content/server/search-document.ts | 64 +- .../src/content/server/search-sync.test.ts | 10 +- packages/vitnode/src/content/types.ts | 2 + packages/vitnode/src/database/search.ts | 6 + packages/vitnode/src/locales/en.json | 22 +- .../src/tanstack/admin/content/list.tsx | 10 +- .../content/actions/visibility-dialog.tsx | 4 +- .../views/admin/views/content/form/index.ts | 4 +- .../views/content/form/publication-label.tsx | 57 + .../admin/views/content/form/save-menu.tsx | 183 + .../views/content/form/status-switch.tsx | 4 +- .../views/content/form/visibility.test.tsx | 4 +- .../admin/views/content/form/visibility.tsx | 44 +- .../admin/views/content/lib/hidden-badge.tsx | 18 +- .../views/admin/views/content/live/root.tsx | 8 +- .../admin/views/content/live/status.test.tsx | 1 - .../views/admin/views/content/live/status.tsx | 27 +- .../views/admin/views/content/live/values.ts | 22 + .../admin/views/content/table/cells.test.tsx | 52 +- .../views/admin/views/content/table/cells.tsx | 40 +- .../admin/views/content/table/columns.tsx | 4 +- .../vitnode/src/views/search/registry.tsx | 2 +- .../src/views/search/search-feed-content.tsx | 204 +- packages/vitnode/src/views/search/types.ts | 19 +- plugins/blog/src/content/post.ts | 1 + plugins/blog/src/locales/en.json | 7 +- plugins/blog/src/pages/post-page.tsx | 22 +- .../admin/article/editor/article-editor.tsx | 120 +- .../src/views/admin/article/editor/fields.tsx | 36 +- 65 files changed, 35462 insertions(+), 345 deletions(-) create mode 100644 apps/api/migrations/20261011111627_add_search_index_is_hidden/migration.sql create mode 100644 apps/api/migrations/20261011111627_add_search_index_is_hidden/snapshot.json create mode 100644 apps/api/migrations/20261011120604_add_search_index_image/migration.sql create mode 100644 apps/api/migrations/20261011120604_add_search_index_image/snapshot.json create mode 100644 packages/vitnode/src/components/hidden-icon.tsx create mode 100644 packages/vitnode/src/content/server/hidden-access.ts create mode 100644 packages/vitnode/src/views/admin/views/content/form/publication-label.tsx create mode 100644 packages/vitnode/src/views/admin/views/content/form/save-menu.tsx diff --git a/apps/api/migrations/20261011111627_add_search_index_is_hidden/migration.sql b/apps/api/migrations/20261011111627_add_search_index_is_hidden/migration.sql new file mode 100644 index 0000000000..6f6a44372e --- /dev/null +++ b/apps/api/migrations/20261011111627_add_search_index_is_hidden/migration.sql @@ -0,0 +1 @@ +ALTER TABLE "core_search_index" ADD COLUMN "isHidden" boolean DEFAULT false NOT NULL; \ No newline at end of file diff --git a/apps/api/migrations/20261011111627_add_search_index_is_hidden/snapshot.json b/apps/api/migrations/20261011111627_add_search_index_is_hidden/snapshot.json new file mode 100644 index 0000000000..37aa72e06f --- /dev/null +++ b/apps/api/migrations/20261011111627_add_search_index_is_hidden/snapshot.json @@ -0,0 +1,17009 @@ +{ + "version": "8", + "dialect": "postgres", + "id": "a3b555f1-3780-4d7e-9714-a4fa88b4b704", + "prevIds": [ + "680724d8-a62f-48ca-aa75-7e3f07114a50" + ], + "ddl": [ + { + "isRlsEnabled": true, + "name": "core_admin_permissions", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_admin_sessions", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_ai_action_settings", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_ai_budget_periods", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_ai_calls", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_ai_cost_adjustments", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_ai_reservations", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_ai_role_permissions", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_ai_role_policies", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_ai_runs", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_ai_settings", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_ai_translation_sources", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_ai_user_overrides", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_content_documents", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_content_drafts", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_content_field_locks", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_content_file_refs", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_content_revisions", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_content_schedules", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_content_slug_history", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_cron", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_admin_dashboard", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_files", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_files_alt", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_files_alt_analysis", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_files_alt_state", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_languages", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_languages_words", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_logs", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_moderators_permissions", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_navigation", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_notification_deliveries", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_notification_events", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_notification_receipts", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_notification_settings", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_notification_user_state", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_notifications", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_page_layouts", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users_passkey_challenges", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users_passkeys", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_queue", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_roles", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_search_index", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_secrets", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_sessions", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_sessions_known_devices", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users_confirm_emails", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users_forgot_password", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users_secondary_roles", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users_sso", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users_sso_operations", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users_sso_profile_sources", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "blog_categories", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "blog_categories_translations", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "blog_posts", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "blog_posts_author_id", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "blog_posts_category_id", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "blog_posts_translations", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_advanced_articles", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_advanced_articles_categories", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_advanced_articles_faq", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_advanced_articles_related_articles", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_advanced_articles_tags", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_advanced_articles_translations", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_articles", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_articles_gallery", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_categories", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_events", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_events_gallery", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_events_related_events", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_events_sessions", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_events_tags", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_events_translations", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_localized_articles", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_localized_articles_translations", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_pages", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_tags", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_tags_related_tags", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_tags_translations", + "entityType": "tables", + "schema": "public" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "roleId", + "entityType": "columns", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "protected", + "entityType": "columns", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "unrestricted", + "entityType": "columns", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "permissions", + "entityType": "columns", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "token", + "entityType": "columns", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "lastSeen", + "entityType": "columns", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expiresAt", + "entityType": "columns", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "deviceId", + "entityType": "columns", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "actionKey", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "enabled", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "modelId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "fallbackModelId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "maxInputCharacters", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "maxOutputTokens", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "timeoutMs", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "maxRetries", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "maxSteps", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "dailyLimit", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "instructions", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_ai_budget_periods" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "scopeKey", + "entityType": "columns", + "schema": "public", + "table": "core_ai_budget_periods" + }, + { + "type": "varchar(10)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "unit", + "entityType": "columns", + "schema": "public", + "table": "core_ai_budget_periods" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "periodStart", + "entityType": "columns", + "schema": "public", + "table": "core_ai_budget_periods" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "periodEnd", + "entityType": "columns", + "schema": "public", + "table": "core_ai_budget_periods" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "limitAmount", + "entityType": "columns", + "schema": "public", + "table": "core_ai_budget_periods" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'0'", + "generated": null, + "identity": null, + "name": "spentAmount", + "entityType": "columns", + "schema": "public", + "table": "core_ai_budget_periods" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'0'", + "generated": null, + "identity": null, + "name": "reservedAmount", + "entityType": "columns", + "schema": "public", + "table": "core_ai_budget_periods" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "unknownCount", + "entityType": "columns", + "schema": "public", + "table": "core_ai_budget_periods" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_budget_periods" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "runId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "attempt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "modelId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "provider", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerModelId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerRequestId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "errorCode", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "inputTokens", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "outputTokens", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "cacheReadTokens", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "cacheWriteTokens", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "reasoningTokens", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "images", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "costUsd", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "costSource", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "costReason", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pricingVersion", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pricingSnapshot", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "startedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "finishedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "reconciledAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "callId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "runId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "previousCostUsd", + "entityType": "columns", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "previousSource", + "entityType": "columns", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "newCostUsd", + "entityType": "columns", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "newSource", + "entityType": "columns", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "deltaUsd", + "entityType": "columns", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "reason", + "entityType": "columns", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_ai_reservations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "runId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_reservations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "budgetPeriodId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_reservations" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "amount", + "entityType": "columns", + "schema": "public", + "table": "core_ai_reservations" + }, + { + "type": "varchar(10)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'active'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "core_ai_reservations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_reservations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "settledAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_reservations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "roleId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_role_permissions" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "permission", + "entityType": "columns", + "schema": "public", + "table": "core_ai_role_permissions" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "granted", + "entityType": "columns", + "schema": "public", + "table": "core_ai_role_permissions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "dailyLimit", + "entityType": "columns", + "schema": "public", + "table": "core_ai_role_permissions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "roleId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_role_policies" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "monthlyPoints", + "entityType": "columns", + "schema": "public", + "table": "core_ai_role_policies" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "unlimited", + "entityType": "columns", + "schema": "public", + "table": "core_ai_role_policies" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_role_policies" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "actionKey", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(10)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "actorType", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "idempotencyKey", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "resourceType", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "resourceId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "errorCode", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "promptVersion", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pointsConversionVersion", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "modelId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "provider", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerModelId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "inputTokens", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "outputTokens", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "cacheReadTokens", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "cacheWriteTokens", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "reasoningTokens", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "costUsd", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "costSource", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "chargedUsd", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "chargedPoints", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'0'", + "generated": null, + "identity": null, + "name": "reservedUsd", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'0'", + "generated": null, + "identity": null, + "name": "reservedPoints", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'pending'", + "generated": null, + "identity": null, + "name": "settlement", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "leaseExpiresAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "accepted", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "acceptedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "sourceFingerprint", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "startedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "finishedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "settledAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "enabled", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "monthlyBudgetUsd", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "systemMonthlyBudgetUsd", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'0'", + "generated": null, + "identity": null, + "name": "defaultMonthlyPoints", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "pointsConversionVersion", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "10", + "generated": null, + "identity": null, + "name": "userRequestsPerMinute", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "2", + "generated": null, + "identity": null, + "name": "userConcurrency", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "2", + "generated": null, + "identity": null, + "name": "systemConcurrency", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "365", + "generated": null, + "identity": null, + "name": "historyRetentionDays", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "altEnabled", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "10", + "generated": null, + "identity": null, + "name": "altBatchSize", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "altScanCursor", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "altScanStartedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_ai_translation_sources" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "contentTypeId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_translation_sources" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_translation_sources" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "locale", + "entityType": "columns", + "schema": "public", + "table": "core_ai_translation_sources" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "field", + "entityType": "columns", + "schema": "public", + "table": "core_ai_translation_sources" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "sourceLocale", + "entityType": "columns", + "schema": "public", + "table": "core_ai_translation_sources" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "sourceFingerprint", + "entityType": "columns", + "schema": "public", + "table": "core_ai_translation_sources" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "targetFingerprint", + "entityType": "columns", + "schema": "public", + "table": "core_ai_translation_sources" + }, + { + "type": "varchar(10)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "origin", + "entityType": "columns", + "schema": "public", + "table": "core_ai_translation_sources" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updatedById", + "entityType": "columns", + "schema": "public", + "table": "core_ai_translation_sources" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_translation_sources" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_user_overrides" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "monthlyPoints", + "entityType": "columns", + "schema": "public", + "table": "core_ai_user_overrides" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "unlimited", + "entityType": "columns", + "schema": "public", + "table": "core_ai_user_overrides" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "blocked", + "entityType": "columns", + "schema": "public", + "table": "core_ai_user_overrides" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_user_overrides" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_content_documents" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "contentTypeId", + "entityType": "columns", + "schema": "public", + "table": "core_content_documents" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "core_content_documents" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "field", + "entityType": "columns", + "schema": "public", + "table": "core_content_documents" + }, + { + "type": "varchar(35)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "language", + "entityType": "columns", + "schema": "public", + "table": "core_content_documents" + }, + { + "type": "bytea", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "state", + "entityType": "columns", + "schema": "public", + "table": "core_content_documents" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "baseVersion", + "entityType": "columns", + "schema": "public", + "table": "core_content_documents" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_documents" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updatedBy", + "entityType": "columns", + "schema": "public", + "table": "core_content_documents" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_content_drafts" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "contentTypeId", + "entityType": "columns", + "schema": "public", + "table": "core_content_drafts" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "core_content_drafts" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "language", + "entityType": "columns", + "schema": "public", + "table": "core_content_drafts" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "values", + "entityType": "columns", + "schema": "public", + "table": "core_content_drafts" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "baseVersion", + "entityType": "columns", + "schema": "public", + "table": "core_content_drafts" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_drafts" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updatedBy", + "entityType": "columns", + "schema": "public", + "table": "core_content_drafts" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_content_field_locks" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "contentTypeId", + "entityType": "columns", + "schema": "public", + "table": "core_content_field_locks" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "core_content_field_locks" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "field", + "entityType": "columns", + "schema": "public", + "table": "core_content_field_locks" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "language", + "entityType": "columns", + "schema": "public", + "table": "core_content_field_locks" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_content_field_locks" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "acquiredAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_field_locks" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expiresAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_field_locks" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_content_file_refs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "revisionId", + "entityType": "columns", + "schema": "public", + "table": "core_content_file_refs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "fileId", + "entityType": "columns", + "schema": "public", + "table": "core_content_file_refs" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_file_refs" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "contentTypeId", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "languageId", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "operation", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "snapshot", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "changedFields", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'system'", + "generated": null, + "identity": null, + "name": "actorType", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "actorUserId", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "restoredFromRevisionId", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "contentTypeId", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "action", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "scheduledFor", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "generation", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'pending'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "createdBy", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "completedAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastError", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "effectsError", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "contentTypeId", + "entityType": "columns", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "languageId", + "entityType": "columns", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "type": "varchar(512)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "path", + "entityType": "columns", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "retiredAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_cron" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "core_cron" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "description", + "entityType": "columns", + "schema": "public", + "table": "core_cron" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastRun", + "entityType": "columns", + "schema": "public", + "table": "core_cron" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_cron" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_cron" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "module", + "entityType": "columns", + "schema": "public", + "table": "core_cron" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "nextRun", + "entityType": "columns", + "schema": "public", + "table": "core_cron" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "schedule", + "entityType": "columns", + "schema": "public", + "table": "core_cron" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_admin_dashboard" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_admin_dashboard" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "widgets", + "entityType": "columns", + "schema": "public", + "table": "core_admin_dashboard" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_admin_dashboard" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_admin_dashboard" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "varchar(512)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "key", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "folder", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "mimeType", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "size", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "metadata", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "fingerprint", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'automatic'", + "generated": null, + "identity": null, + "name": "altPolicy", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "fileId", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "languageCode", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "text", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt" + }, + { + "type": "varchar(10)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "origin", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "fileFingerprint", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "runId", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updatedById", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt_analysis" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "fileId", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt_analysis" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "fileFingerprint", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt_analysis" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "description", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt_analysis" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "runId", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt_analysis" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt_analysis" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "fileId", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt_state" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt_state" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastError", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt_state" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt_state" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_languages" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "code", + "entityType": "columns", + "schema": "public", + "table": "core_languages" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "core_languages" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'UTC'", + "generated": null, + "identity": null, + "name": "timezone", + "entityType": "columns", + "schema": "public", + "table": "core_languages" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "protected", + "entityType": "columns", + "schema": "public", + "table": "core_languages" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "default", + "entityType": "columns", + "schema": "public", + "table": "core_languages" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_languages" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_languages" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "time24", + "entityType": "columns", + "schema": "public", + "table": "core_languages" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_languages_words" + }, + { + "type": "varchar", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "languageCode", + "entityType": "columns", + "schema": "public", + "table": "core_languages_words" + }, + { + "type": "varchar(50)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginCode", + "entityType": "columns", + "schema": "public", + "table": "core_languages_words" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "core_languages_words" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "value", + "entityType": "columns", + "schema": "public", + "table": "core_languages_words" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "tableName", + "entityType": "columns", + "schema": "public", + "table": "core_languages_words" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "variable", + "entityType": "columns", + "schema": "public", + "table": "core_languages_words" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "varchar(10)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "type", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "content", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "varchar(45)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ipAddress", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "varchar(10)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'GET'", + "generated": null, + "identity": null, + "name": "method", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'localhost'", + "generated": null, + "identity": null, + "name": "path", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userAgent", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "500", + "generated": null, + "identity": null, + "name": "statusCode", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "roleId", + "entityType": "columns", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "protected", + "entityType": "columns", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "unrestricted", + "entityType": "columns", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "permissions", + "entityType": "columns", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "parentId", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'header'", + "generated": null, + "identity": null, + "name": "location", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "kind", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "varchar(50)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "varchar(120)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "presetId", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "href", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "icon", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "isOpenInNewTab", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "bigserial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "channel", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "mode", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "eventId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "idempotencyKey", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'pending'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "attempts", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "5", + "generated": null, + "identity": null, + "name": "maxAttempts", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "availableAt", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "periodStart", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "periodEnd", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "itemCount", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerMessageId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "varchar(500)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastError", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "varchar(50)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "skipReason", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "sentAt", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "bigserial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "type", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "schemaVersion", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "idempotencyKey", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "actorId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "subjectType", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "subjectId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "groupKey", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "data", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 1, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "recipientIds", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "allowSelf", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'pending'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "recipientCursor", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "deliveredCount", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastError", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "completedAt", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "eventId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "notificationId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "seq", + "entityType": "columns", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "emailPending", + "entityType": "columns", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "emailDeliveryId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "key", + "entityType": "columns", + "schema": "public", + "table": "core_notification_settings" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "value", + "entityType": "columns", + "schema": "public", + "table": "core_notification_settings" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_notification_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_user_state" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "unreadCount", + "entityType": "columns", + "schema": "public", + "table": "core_notification_user_state" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "revision", + "entityType": "columns", + "schema": "public", + "table": "core_notification_user_state" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "preferences", + "entityType": "columns", + "schema": "public", + "table": "core_notification_user_state" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_notification_user_state" + }, + { + "type": "bigserial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "type", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "varchar(50)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "category", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "groupKey", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "groupBucket", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "subjectType", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "subjectId", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "latestEventId", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "eventCount", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "activitySeq", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "readSeq", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "readAt", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "archivedAt", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "lastActivityAt", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "varchar(120)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pageId", + "entityType": "columns", + "schema": "public", + "table": "core_page_layouts" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "zones", + "entityType": "columns", + "schema": "public", + "table": "core_page_layouts" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_page_layouts" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_page_layouts" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "tokenHash", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ceremony", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "varchar(128)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "challenge", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "varchar(128)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "webauthnUserId", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expiresAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "varchar(1024)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "credentialId", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publicKey", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "counter", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "varchar(128)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "webauthnUserId", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 1, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "transports", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "deviceType", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "backedUp", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "varchar(36)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "aaguid", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastUsedAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'default'", + "generated": null, + "identity": null, + "name": "queue", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'pending'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "payload", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "priority", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "attempts", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "3", + "generated": null, + "identity": null, + "name": "maxAttempts", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "availableAt", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "reservedAt", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastError", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "completedAt", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "dedupeKey", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "protected", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "default", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "root", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "guest", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "varchar(50)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "color", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "prefix", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "allowUploadFiles", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "totalMaxStorage", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "maxStorageForSubmit", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "allowUploadAvatar", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "2048", + "generated": null, + "identity": null, + "name": "maxAvatarSize", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "allowUploadCover", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "5120", + "generated": null, + "identity": null, + "name": "maxCoverSize", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "allowEditPersonalInfo", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemType", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "languageCode", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 1, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "authorIds", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "content", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "tsvector", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": { + "as": "setweight(to_tsvector(CASE lower(split_part(\"core_search_index\".\"languageCode\", '-', 1)) WHEN 'da' THEN 'danish'::regconfig WHEN 'de' THEN 'german'::regconfig WHEN 'en' THEN 'english'::regconfig WHEN 'es' THEN 'spanish'::regconfig WHEN 'fi' THEN 'finnish'::regconfig WHEN 'fr' THEN 'french'::regconfig WHEN 'hu' THEN 'hungarian'::regconfig WHEN 'it' THEN 'italian'::regconfig WHEN 'nl' THEN 'dutch'::regconfig WHEN 'no' THEN 'norwegian'::regconfig WHEN 'pl' THEN 'polish'::regconfig WHEN 'pt' THEN 'portuguese'::regconfig WHEN 'ro' THEN 'romanian'::regconfig WHEN 'ru' THEN 'russian'::regconfig WHEN 'sv' THEN 'swedish'::regconfig WHEN 'tr' THEN 'turkish'::regconfig ELSE 'simple'::regconfig END, coalesce(\"core_search_index\".\"title\", '')), 'A') || setweight(to_tsvector(CASE lower(split_part(\"core_search_index\".\"languageCode\", '-', 1)) WHEN 'da' THEN 'danish'::regconfig WHEN 'de' THEN 'german'::regconfig WHEN 'en' THEN 'english'::regconfig WHEN 'es' THEN 'spanish'::regconfig WHEN 'fi' THEN 'finnish'::regconfig WHEN 'fr' THEN 'french'::regconfig WHEN 'hu' THEN 'hungarian'::regconfig WHEN 'it' THEN 'italian'::regconfig WHEN 'nl' THEN 'dutch'::regconfig WHEN 'no' THEN 'norwegian'::regconfig WHEN 'pl' THEN 'polish'::regconfig WHEN 'pt' THEN 'portuguese'::regconfig WHEN 'ro' THEN 'romanian'::regconfig WHEN 'ru' THEN 'russian'::regconfig WHEN 'sv' THEN 'swedish'::regconfig WHEN 'tr' THEN 'turkish'::regconfig ELSE 'simple'::regconfig END, coalesce(\"core_search_index\".\"content\", '')), 'B')", + "type": "stored" + }, + "identity": null, + "name": "search_vector", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "containerType", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "containerId", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "url", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "isPublic", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "isHidden", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "metadata", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "indexedAt", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "core_secrets" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "value", + "entityType": "columns", + "schema": "public", + "table": "core_secrets" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_secrets" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_sessions" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "token", + "entityType": "columns", + "schema": "public", + "table": "core_sessions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_sessions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_sessions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expiresAt", + "entityType": "columns", + "schema": "public", + "table": "core_sessions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "deviceId", + "entityType": "columns", + "schema": "public", + "table": "core_sessions" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_sessions_known_devices" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publicId", + "entityType": "columns", + "schema": "public", + "table": "core_sessions_known_devices" + }, + { + "type": "varchar(40)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ipAddress", + "entityType": "columns", + "schema": "public", + "table": "core_sessions_known_devices" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userAgent", + "entityType": "columns", + "schema": "public", + "table": "core_sessions_known_devices" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "lastSeen", + "entityType": "columns", + "schema": "public", + "table": "core_sessions_known_devices" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "nameCode", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "email", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(128)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "firstName", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(128)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastName", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "phone", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "headline", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "showRealName", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "password", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "newsletter", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(6)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "avatarColor", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "emailVerified", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "roleId", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "birthday", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "timeZone", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(40)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ipAddress", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'en'", + "generated": null, + "identity": null, + "name": "language", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "avatarId", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "coverId", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_users_confirm_emails" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_users_confirm_emails" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "token", + "entityType": "columns", + "schema": "public", + "table": "core_users_confirm_emails" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_confirm_emails" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expiresAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_confirm_emails" + }, + { + "type": "varchar(40)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ipAddress", + "entityType": "columns", + "schema": "public", + "table": "core_users_confirm_emails" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_users_forgot_password" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_users_forgot_password" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "token", + "entityType": "columns", + "schema": "public", + "table": "core_users_forgot_password" + }, + { + "type": "varchar(40)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ipAddress", + "entityType": "columns", + "schema": "public", + "table": "core_users_forgot_password" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_forgot_password" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expiresAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_forgot_password" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_users_secondary_roles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "roleId", + "entityType": "columns", + "schema": "public", + "table": "core_users_secondary_roles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_secondary_roles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerAccountId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerEmail", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerUsername", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "syncOnSignIn", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "varchar(2048)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "avatarSourceUrl", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "avatarSha256", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "avatarFileId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "tokenHash", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "intent", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 1, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "fields", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerAccountId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "preview", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expiresAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_profile_sources" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "field", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_profile_sources" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_profile_sources" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_profile_sources" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "blog_categories" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "blog_categories" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "blog_categories" + }, + { + "type": "varchar(50)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "color", + "entityType": "columns", + "schema": "public", + "table": "blog_categories" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "languageId", + "entityType": "columns", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "blog_posts" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "blog_posts" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "blog_posts" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hiddenAt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hiddenBy", + "entityType": "columns", + "schema": "public", + "table": "blog_posts" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "coverImage", + "entityType": "columns", + "schema": "public", + "table": "blog_posts" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "relatedItemId", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "relatedItemId", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "languageId", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "friendlyUrl", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "content", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "excerpt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "coverImageAlt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "syndicationIndexable", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "syndicationNoIndex", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "5", + "generated": null, + "identity": null, + "name": "syndicationPriority", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_categories" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "relatedItemId", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_categories" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_categories" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_categories" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_faq" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_faq" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_faq" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_faq" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_faq" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "question", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_faq" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "answer", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_faq" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_related_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "relatedItemId", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_related_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_related_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_related_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_tags" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "relatedItemId", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_tags" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_tags" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_tags" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "languageId", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "seoTitle", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "seoDescription", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "code", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "excerpt", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "views", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "featured", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "noIndex", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "author", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "animation", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "category", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "example_articles_gallery" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "relatedItemId", + "entityType": "columns", + "schema": "public", + "table": "example_articles_gallery" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "example_articles_gallery" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_articles_gallery" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "example_categories" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_categories" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_categories" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "example_categories" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": { + "type": "byDefault", + "name": "example_events_id_seq", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": 1, + "cycle": false + }, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "example_events" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_events" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_events" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "example_events" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "example_events" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "example_events" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hiddenAt", + "entityType": "columns", + "schema": "public", + "table": "example_events" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hiddenBy", + "entityType": "columns", + "schema": "public", + "table": "example_events" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "primaryTag", + "entityType": "columns", + "schema": "public", + "table": "example_events" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "category", + "entityType": "columns", + "schema": "public", + "table": "example_events" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "example_events_gallery" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "relatedItemId", + "entityType": "columns", + "schema": "public", + "table": "example_events_gallery" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "example_events_gallery" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_events_gallery" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "example_events_related_events" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "relatedItemId", + "entityType": "columns", + "schema": "public", + "table": "example_events_related_events" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "example_events_related_events" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_events_related_events" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "example_events_sessions" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "example_events_sessions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "example_events_sessions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_events_sessions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_events_sessions" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "example_events_sessions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "startsAt", + "entityType": "columns", + "schema": "public", + "table": "example_events_sessions" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "example_events_tags" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "relatedItemId", + "entityType": "columns", + "schema": "public", + "table": "example_events_tags" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "example_events_tags" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_events_tags" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "example_events_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "languageId", + "entityType": "columns", + "schema": "public", + "table": "example_events_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "example_events_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_events_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_events_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "example_events_translations" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "example_events_translations" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "example_events_translations" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "example_events_translations" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "body", + "entityType": "columns", + "schema": "public", + "table": "example_events_translations" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "featured", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "languageId", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "body", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "example_pages" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_pages" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_pages" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "example_pages" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "example_pages" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "example_pages" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "example_pages" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "content", + "entityType": "columns", + "schema": "public", + "table": "example_pages" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "example_tags" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_tags" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_tags" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "example_tags" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "example_tags" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "example_tags" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hiddenAt", + "entityType": "columns", + "schema": "public", + "table": "example_tags" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hiddenBy", + "entityType": "columns", + "schema": "public", + "table": "example_tags" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "category", + "entityType": "columns", + "schema": "public", + "table": "example_tags" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "example_tags_related_tags" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "relatedItemId", + "entityType": "columns", + "schema": "public", + "table": "example_tags_related_tags" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "example_tags_related_tags" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_tags_related_tags" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "example_tags_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "languageId", + "entityType": "columns", + "schema": "public", + "table": "example_tags_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "example_tags_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_tags_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_tags_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "example_tags_translations" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "example_tags_translations" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "example_tags_translations" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "example_tags_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "roleId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_admin_permissions_role_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_admin_permissions_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_admin_permissions_updated_at_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_admin_sessions_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "deviceId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_admin_sessions_device_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "scopeKey", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "periodStart", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_ai_budget_periods_scope_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_ai_budget_periods" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "runId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_ai_calls_run_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_ai_calls" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "costSource", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "reconciledAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_ai_calls_reconcile_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_ai_calls" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "callId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_ai_cost_adjustments_call_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "runId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "budgetPeriodId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_ai_reservations_run_period_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_ai_reservations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_ai_reservations_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_ai_reservations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_ai_runs_user_created_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_ai_runs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_ai_runs_created_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_ai_runs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "actionKey", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_ai_runs_action_created_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_ai_runs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "leaseExpiresAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_ai_runs_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_ai_runs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "actorType", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "idempotencyKey", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": "\"idempotencyKey\" IS NOT NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_ai_runs_idempotency_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_ai_runs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "locale", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "field", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_ai_translation_sources_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_ai_translation_sources" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "field", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "language", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_documents_doc_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_content_documents" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_documents_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_documents" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedBy", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_documents_updated_by_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_documents" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "language", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_drafts_key_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_content_drafts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_drafts_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_drafts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedBy", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_drafts_updated_by_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_drafts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "field", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "language", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_field_locks_key_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_content_field_locks" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "expiresAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_field_locks_expires_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_field_locks" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_field_locks_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_field_locks" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "revisionId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "fileId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_file_refs_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_content_file_refs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "fileId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_file_refs_file_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_file_refs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "version", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": "\"languageId\" IS NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_revisions_item_version_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_content_revisions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "version", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": "\"languageId\" IS NOT NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_revisions_translation_version_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_content_revisions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "version", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_revisions_language_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_revisions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "pluginId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_revisions_plugin_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_revisions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "actorUserId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_revisions_actor_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_revisions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "action", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": "status = 'pending'", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_schedules_active_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_content_schedules" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "scheduledFor", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_schedules_due_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_schedules" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_schedules_item_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_schedules" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "pluginId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_schedules_plugin_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_schedules" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdBy", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_schedules_created_by_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_schedules" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": "\"languageId\" IS NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_slug_history_shared_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": "\"languageId\" IS NOT NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_slug_history_locale_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_slug_history_item_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "pluginId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_slug_history_plugin_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "lastRun", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_cron_last_run_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_cron" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_admin_dashboard_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_admin_dashboard" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_files_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_files" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_files_created_at_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_files" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "fileId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "languageCode", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_files_alt_file_language_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_files_alt" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "fileId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "fileFingerprint", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_files_alt_analysis_file_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_files_alt_analysis" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": "status <> 'completed'", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_files_alt_state_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_files_alt_state" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "name", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_languages_name_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_languages" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageCode", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_languages_words_lang_code_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_languages_words" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "tableName", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "pluginCode", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "variable", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_languages_words_lookup_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_languages_words" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_logs_created_at_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_logs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_logs_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_logs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "roleId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_moderators_permissions_role_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_moderators_permissions_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_moderators_permissions_updated_at_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_navigation_position_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_navigation" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "location", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_navigation_location_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_navigation" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "parentId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_navigation_parent_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_navigation" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "availableAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": "status = 'pending'", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_deliveries_claim_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_deliveries_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_deliveries_user_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "pluginId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "type", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "idempotencyKey", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_events_idempotency_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_events_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_events_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "notificationId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "seq", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_receipts_notification_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_receipts_user_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": "\"emailPending\" = true AND \"emailDeliveryId\" IS NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_receipts_email_pending_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "unreadCount", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_user_state_unread_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_user_state" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "pluginId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "type", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "groupKey", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "groupBucket", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notifications_group_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_notifications" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "lastActivityAt", + "isExpression": false, + "asc": false, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": false, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": "\"archivedAt\" IS NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notifications_inbox_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notifications" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "lastActivityAt", + "isExpression": false, + "asc": false, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": false, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": "\"archivedAt\" IS NULL AND \"readSeq\" < \"activitySeq\"", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notifications_unread_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notifications" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "subjectType", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "subjectId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notifications_subject_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notifications" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "lastActivityAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notifications_last_activity_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notifications" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "latestEventId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notifications_latest_event_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notifications" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "expiresAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_passkey_challenges_expires_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_passkey_challenges_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_passkeys_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "webauthnUserId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_passkeys_webauthn_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "availableAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_queue_status_available_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_queue" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_queue_created_at_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_queue" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "pluginId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "dedupeKey", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": "\"dedupeKey\" IS NOT NULL AND status IN ('pending', 'processing')", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_queue_dedupe_active_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_queue" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_roles_updated_at_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_roles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "search_vector", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "gin", + "concurrently": false, + "name": "core_search_index_search_vector_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_search_index" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_search_index_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_search_index" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "authorIds", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "gin", + "concurrently": false, + "name": "core_search_index_author_ids_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_search_index" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemType", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_search_index_item_type_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_search_index" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageCode", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_search_index_language_code_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_search_index" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "isPublic", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_search_index_is_public_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_search_index" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_sessions_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_sessions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "deviceId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_sessions_device_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_sessions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "ipAddress", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_sessions_known_devices_ip_address_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_sessions_known_devices" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "avatarId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_avatar_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "coverId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_cover_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_created_at_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "roleId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_secondary_roles_role_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_secondary_roles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_sso_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_sso" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "providerId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_sso_operations_user_provider_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "expiresAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_sso_operations_expires_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_categories_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_categories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_categories_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_categories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_categories_translations_language_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_status_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "coverImage", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_cover_image_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_status_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "hiddenBy", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_hidden_by_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_author_id_position_key", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "relatedItemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_author_id_related_item_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_category_id_position_key", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "relatedItemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_category_id_related_item_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_translations_language_id_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "friendlyUrl", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_translations_language_id_friendly_url_key", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "syndicationPriority", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_syndication_priority_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_status_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_categories_position_key", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles_categories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "relatedItemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_categories_related_item_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles_categories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_faq_position_key", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles_faq" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_related_articles_position_key", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles_related_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "relatedItemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_related_articles_related_item_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles_related_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_tags_position_key", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles_tags" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "relatedItemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_tags_related_item_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles_tags" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_translations_language_id_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_translations_language_id_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_status_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "code", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_code_key", + "entityType": "indexes", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "author", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_author_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "animation", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_animation_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "category", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_category_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_status_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_gallery_position_key", + "entityType": "indexes", + "schema": "public", + "table": "example_articles_gallery" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "relatedItemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_gallery_related_item_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_articles_gallery" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_categories_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_categories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_categories_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_categories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "primaryTag", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_events_primary_tag_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "category", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_events_category_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_events_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_events_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_events_status_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "hiddenBy", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_events_hidden_by_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_events_gallery_position_key", + "entityType": "indexes", + "schema": "public", + "table": "example_events_gallery" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "relatedItemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_events_gallery_related_item_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_events_gallery" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_events_related_events_position_key", + "entityType": "indexes", + "schema": "public", + "table": "example_events_related_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "relatedItemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_events_related_events_related_item_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_events_related_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_events_sessions_position_key", + "entityType": "indexes", + "schema": "public", + "table": "example_events_sessions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_events_tags_position_key", + "entityType": "indexes", + "schema": "public", + "table": "example_events_tags" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "relatedItemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_events_tags_related_item_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_events_tags" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_events_translations_language_id_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_events_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_events_translations_language_id_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "example_events_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_localized_articles_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_localized_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_localized_articles_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_localized_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_localized_articles_status_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_localized_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_localized_articles_translations_language_id_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_localized_articles_translations_language_id_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_pages_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "example_pages" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_pages_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_pages" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_pages_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_pages" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_pages_status_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_pages" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "category", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_tags_category_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_tags" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_tags_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_tags" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_tags_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_tags" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_tags_status_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_tags" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "hiddenBy", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_tags_hidden_by_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_tags" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_tags_related_tags_position_key", + "entityType": "indexes", + "schema": "public", + "table": "example_tags_related_tags" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "relatedItemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_tags_related_tags_related_item_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_tags_related_tags" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_tags_translations_language_id_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_tags_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_tags_translations_language_id_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "example_tags_translations" + }, + { + "nameExplicit": false, + "columns": [ + "roleId" + ], + "schemaTo": "public", + "tableTo": "core_roles", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_admin_permissions_roleId_core_roles_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_admin_permissions_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_admin_sessions_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "nameExplicit": false, + "columns": [ + "deviceId" + ], + "schemaTo": "public", + "tableTo": "core_sessions_known_devices", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_admin_sessions_deviceId_core_sessions_known_devices_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "nameExplicit": false, + "columns": [ + "runId" + ], + "schemaTo": "public", + "tableTo": "core_ai_runs", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_ai_calls_runId_core_ai_runs_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_ai_calls" + }, + { + "nameExplicit": false, + "columns": [ + "callId" + ], + "schemaTo": "public", + "tableTo": "core_ai_calls", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_ai_cost_adjustments_callId_core_ai_calls_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "nameExplicit": false, + "columns": [ + "runId" + ], + "schemaTo": "public", + "tableTo": "core_ai_runs", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_ai_cost_adjustments_runId_core_ai_runs_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "nameExplicit": false, + "columns": [ + "runId" + ], + "schemaTo": "public", + "tableTo": "core_ai_runs", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_ai_reservations_runId_core_ai_runs_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_ai_reservations" + }, + { + "nameExplicit": false, + "columns": [ + "budgetPeriodId" + ], + "schemaTo": "public", + "tableTo": "core_ai_budget_periods", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_ai_reservations_FClYpPXZwvsm_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_ai_reservations" + }, + { + "nameExplicit": false, + "columns": [ + "roleId" + ], + "schemaTo": "public", + "tableTo": "core_roles", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_ai_role_permissions_roleId_core_roles_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_ai_role_permissions" + }, + { + "nameExplicit": false, + "columns": [ + "roleId" + ], + "schemaTo": "public", + "tableTo": "core_roles", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_ai_role_policies_roleId_core_roles_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_ai_role_policies" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_ai_runs_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_ai_runs" + }, + { + "nameExplicit": false, + "columns": [ + "updatedById" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_ai_translation_sources_updatedById_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_ai_translation_sources" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_ai_user_overrides_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_ai_user_overrides" + }, + { + "nameExplicit": false, + "columns": [ + "updatedBy" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "core_content_documents_updatedBy_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_content_documents" + }, + { + "nameExplicit": false, + "columns": [ + "updatedBy" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "core_content_drafts_updatedBy_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_content_drafts" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "core_content_field_locks_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_content_field_locks" + }, + { + "nameExplicit": false, + "columns": [ + "revisionId" + ], + "schemaTo": "public", + "tableTo": "core_content_revisions", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "core_content_file_refs_5get6SfhBPHr_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_content_file_refs" + }, + { + "nameExplicit": false, + "columns": [ + "fileId" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "core_content_file_refs_fileId_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_content_file_refs" + }, + { + "nameExplicit": false, + "columns": [ + "actorUserId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "core_content_revisions_actorUserId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_content_revisions" + }, + { + "nameExplicit": false, + "columns": [ + "createdBy" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "core_content_schedules_createdBy_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_content_schedules" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_admin_dashboard_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_admin_dashboard" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_files_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_files" + }, + { + "nameExplicit": false, + "columns": [ + "fileId" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_files_alt_fileId_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_files_alt" + }, + { + "nameExplicit": false, + "columns": [ + "languageCode" + ], + "schemaTo": "public", + "tableTo": "core_languages", + "columnsTo": [ + "code" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_files_alt_languageCode_core_languages_code_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_files_alt" + }, + { + "nameExplicit": false, + "columns": [ + "runId" + ], + "schemaTo": "public", + "tableTo": "core_ai_runs", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_files_alt_runId_core_ai_runs_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_files_alt" + }, + { + "nameExplicit": false, + "columns": [ + "updatedById" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_files_alt_updatedById_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_files_alt" + }, + { + "nameExplicit": false, + "columns": [ + "fileId" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_files_alt_analysis_fileId_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_files_alt_analysis" + }, + { + "nameExplicit": false, + "columns": [ + "runId" + ], + "schemaTo": "public", + "tableTo": "core_ai_runs", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_files_alt_analysis_runId_core_ai_runs_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_files_alt_analysis" + }, + { + "nameExplicit": false, + "columns": [ + "fileId" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_files_alt_state_fileId_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_files_alt_state" + }, + { + "nameExplicit": false, + "columns": [ + "languageCode" + ], + "schemaTo": "public", + "tableTo": "core_languages", + "columnsTo": [ + "code" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_languages_words_languageCode_core_languages_code_fk", + "entityType": "fks", + "schema": "public", + "table": "core_languages_words" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "core_logs_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_logs" + }, + { + "nameExplicit": false, + "columns": [ + "roleId" + ], + "schemaTo": "public", + "tableTo": "core_roles", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_moderators_permissions_roleId_core_roles_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_moderators_permissions_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "nameExplicit": false, + "columns": [ + "parentId" + ], + "schemaTo": "public", + "tableTo": "core_navigation", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_navigation_parentId_core_navigation_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_navigation" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_notification_deliveries_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "nameExplicit": false, + "columns": [ + "eventId" + ], + "schemaTo": "public", + "tableTo": "core_notification_events", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_notification_deliveries_vcf37BytIa0M_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "nameExplicit": false, + "columns": [ + "actorId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_notification_events_actorId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_notification_events" + }, + { + "nameExplicit": false, + "columns": [ + "eventId" + ], + "schemaTo": "public", + "tableTo": "core_notification_events", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_notification_receipts_UQ7ZEs0I6ijx_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_notification_receipts_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "nameExplicit": false, + "columns": [ + "notificationId" + ], + "schemaTo": "public", + "tableTo": "core_notifications", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_notification_receipts_Vxye2Cs6MgcL_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_notification_user_state_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_notification_user_state" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_notifications_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_notifications" + }, + { + "nameExplicit": false, + "columns": [ + "latestEventId" + ], + "schemaTo": "public", + "tableTo": "core_notification_events", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "RESTRICT", + "name": "core_notifications_I8QoGbTal5Wt_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_notifications" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_users_passkey_challenges_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_users_passkeys_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_sessions_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_sessions" + }, + { + "nameExplicit": false, + "columns": [ + "deviceId" + ], + "schemaTo": "public", + "tableTo": "core_sessions_known_devices", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_sessions_deviceId_core_sessions_known_devices_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_sessions" + }, + { + "nameExplicit": false, + "columns": [ + "roleId" + ], + "schemaTo": "public", + "tableTo": "core_roles", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "core_users_roleId_core_roles_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_users" + }, + { + "nameExplicit": false, + "columns": [ + "language" + ], + "schemaTo": "public", + "tableTo": "core_languages", + "columnsTo": [ + "code" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET DEFAULT", + "name": "core_users_language_core_languages_code_fk", + "entityType": "fks", + "schema": "public", + "table": "core_users" + }, + { + "nameExplicit": false, + "columns": [ + "avatarId" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_users_avatarId_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_users" + }, + { + "nameExplicit": false, + "columns": [ + "coverId" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_users_coverId_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_users" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_users_confirm_emails_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_users_confirm_emails" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_users_forgot_password_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_users_forgot_password" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_users_secondary_roles_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_users_secondary_roles" + }, + { + "nameExplicit": false, + "columns": [ + "roleId" + ], + "schemaTo": "public", + "tableTo": "core_roles", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_users_secondary_roles_roleId_core_roles_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_users_secondary_roles" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_users_sso_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_users_sso" + }, + { + "nameExplicit": false, + "columns": [ + "avatarFileId" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_users_sso_avatarFileId_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_users_sso" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_users_sso_operations_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_users_sso_profile_sources_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_users_sso_profile_sources" + }, + { + "nameExplicit": true, + "columns": [ + "userId", + "providerId" + ], + "schemaTo": "public", + "tableTo": "core_users_sso", + "columnsTo": [ + "userId", + "providerId" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_users_sso_profile_sources_connection_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_users_sso_profile_sources" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "blog_categories", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "blog_categories_translations_itemId_blog_categories_id_fk", + "entityType": "fks", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "nameExplicit": false, + "columns": [ + "languageId" + ], + "schemaTo": "public", + "tableTo": "core_languages", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "blog_categories_translations_languageId_core_languages_id_fk", + "entityType": "fks", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "nameExplicit": false, + "columns": [ + "hiddenBy" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "blog_posts_hiddenBy_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "blog_posts" + }, + { + "nameExplicit": false, + "columns": [ + "coverImage" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "blog_posts_coverImage_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "blog_posts" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "blog_posts", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "blog_posts_author_id_itemId_blog_posts_id_fk", + "entityType": "fks", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "nameExplicit": false, + "columns": [ + "relatedItemId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "blog_posts_author_id_relatedItemId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "blog_posts", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "blog_posts_category_id_itemId_blog_posts_id_fk", + "entityType": "fks", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "nameExplicit": false, + "columns": [ + "relatedItemId" + ], + "schemaTo": "public", + "tableTo": "blog_categories", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "blog_posts_category_id_relatedItemId_blog_categories_id_fk", + "entityType": "fks", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "blog_posts", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "blog_posts_translations_itemId_blog_posts_id_fk", + "entityType": "fks", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "nameExplicit": false, + "columns": [ + "languageId" + ], + "schemaTo": "public", + "tableTo": "core_languages", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "blog_posts_translations_languageId_core_languages_id_fk", + "entityType": "fks", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "example_advanced_articles", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_advanced_articles_categories_itemId_example_advanced_ar", + "entityType": "fks", + "schema": "public", + "table": "example_advanced_articles_categories" + }, + { + "nameExplicit": false, + "columns": [ + "relatedItemId" + ], + "schemaTo": "public", + "tableTo": "example_categories", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "example_advanced_articles_categories_relatedItemId_example_cate", + "entityType": "fks", + "schema": "public", + "table": "example_advanced_articles_categories" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "example_advanced_articles", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_advanced_articles_faq_itemId_example_advanced_articles_", + "entityType": "fks", + "schema": "public", + "table": "example_advanced_articles_faq" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "example_advanced_articles", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_advanced_articles_related_articles_itemId_example_advan", + "entityType": "fks", + "schema": "public", + "table": "example_advanced_articles_related_articles" + }, + { + "nameExplicit": false, + "columns": [ + "relatedItemId" + ], + "schemaTo": "public", + "tableTo": "example_advanced_articles", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_advanced_articles_related_articles_relatedItemId_exampl", + "entityType": "fks", + "schema": "public", + "table": "example_advanced_articles_related_articles" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "example_advanced_articles", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_advanced_articles_tags_ATchHLRtvoCM_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_advanced_articles_tags" + }, + { + "nameExplicit": false, + "columns": [ + "relatedItemId" + ], + "schemaTo": "public", + "tableTo": "example_tags", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "example_advanced_articles_tags_dEsWdkDmu7nW_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_advanced_articles_tags" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "example_advanced_articles", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_advanced_articles_translations_itemId_example_advanced_", + "entityType": "fks", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "nameExplicit": false, + "columns": [ + "languageId" + ], + "schemaTo": "public", + "tableTo": "core_languages", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "example_advanced_articles_translations_languageId_core_language", + "entityType": "fks", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "nameExplicit": false, + "columns": [ + "author" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "example_articles_author_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": false, + "columns": [ + "animation" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "example_articles_animation_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": false, + "columns": [ + "category" + ], + "schemaTo": "public", + "tableTo": "example_categories", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "example_articles_category_example_categories_id_fk", + "entityType": "fks", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "example_articles", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_articles_gallery_itemId_example_articles_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_articles_gallery" + }, + { + "nameExplicit": false, + "columns": [ + "relatedItemId" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "example_articles_gallery_relatedItemId_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_articles_gallery" + }, + { + "nameExplicit": false, + "columns": [ + "hiddenBy" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "example_events_hiddenBy_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_events" + }, + { + "nameExplicit": false, + "columns": [ + "primaryTag" + ], + "schemaTo": "public", + "tableTo": "example_tags", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "example_events_primaryTag_example_tags_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_events" + }, + { + "nameExplicit": false, + "columns": [ + "category" + ], + "schemaTo": "public", + "tableTo": "example_categories", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "example_events_category_example_categories_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_events" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "example_events", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_events_gallery_itemId_example_events_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_events_gallery" + }, + { + "nameExplicit": false, + "columns": [ + "relatedItemId" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "example_events_gallery_relatedItemId_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_events_gallery" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "example_events", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_events_related_events_itemId_example_events_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_events_related_events" + }, + { + "nameExplicit": false, + "columns": [ + "relatedItemId" + ], + "schemaTo": "public", + "tableTo": "example_events", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_events_related_events_oaRvqnKyAS7L_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_events_related_events" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "example_events", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_events_sessions_itemId_example_events_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_events_sessions" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "example_events", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_events_tags_itemId_example_events_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_events_tags" + }, + { + "nameExplicit": false, + "columns": [ + "relatedItemId" + ], + "schemaTo": "public", + "tableTo": "example_tags", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "example_events_tags_relatedItemId_example_tags_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_events_tags" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "example_events", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_events_translations_itemId_example_events_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_events_translations" + }, + { + "nameExplicit": false, + "columns": [ + "languageId" + ], + "schemaTo": "public", + "tableTo": "core_languages", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "example_events_translations_languageId_core_languages_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_events_translations" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "example_localized_articles", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_localized_articles_translations_itemId_example_localize", + "entityType": "fks", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "nameExplicit": false, + "columns": [ + "languageId" + ], + "schemaTo": "public", + "tableTo": "core_languages", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "example_localized_articles_translations_languageId_core_languag", + "entityType": "fks", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "nameExplicit": false, + "columns": [ + "hiddenBy" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "example_tags_hiddenBy_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_tags" + }, + { + "nameExplicit": false, + "columns": [ + "category" + ], + "schemaTo": "public", + "tableTo": "example_categories", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "example_tags_category_example_categories_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_tags" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "example_tags", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_tags_related_tags_itemId_example_tags_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_tags_related_tags" + }, + { + "nameExplicit": false, + "columns": [ + "relatedItemId" + ], + "schemaTo": "public", + "tableTo": "example_tags", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_tags_related_tags_relatedItemId_example_tags_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_tags_related_tags" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "example_tags", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_tags_translations_itemId_example_tags_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_tags_translations" + }, + { + "nameExplicit": false, + "columns": [ + "languageId" + ], + "schemaTo": "public", + "tableTo": "core_languages", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "example_tags_translations_languageId_core_languages_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_tags_translations" + }, + { + "columns": [ + "roleId", + "permission" + ], + "nameExplicit": false, + "name": "core_ai_role_permissions_pkey", + "entityType": "pks", + "schema": "public", + "table": "core_ai_role_permissions" + }, + { + "columns": [ + "eventId", + "userId" + ], + "nameExplicit": false, + "name": "core_notification_receipts_pkey", + "entityType": "pks", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "columns": [ + "userId", + "roleId" + ], + "nameExplicit": false, + "name": "core_users_secondary_roles_userId_roleId_pk", + "entityType": "pks", + "schema": "public", + "table": "core_users_secondary_roles" + }, + { + "columns": [ + "userId", + "field" + ], + "nameExplicit": false, + "name": "core_users_sso_profile_sources_pkey", + "entityType": "pks", + "schema": "public", + "table": "core_users_sso_profile_sources" + }, + { + "columns": [ + "itemId", + "languageId" + ], + "nameExplicit": true, + "name": "blog_categories_translations_item_id_language_id_pk", + "entityType": "pks", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "columns": [ + "itemId", + "relatedItemId" + ], + "nameExplicit": true, + "name": "blog_posts_author_id_pk", + "entityType": "pks", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "columns": [ + "itemId", + "relatedItemId" + ], + "nameExplicit": true, + "name": "blog_posts_category_id_pk", + "entityType": "pks", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "columns": [ + "itemId", + "languageId" + ], + "nameExplicit": true, + "name": "blog_posts_translations_item_id_language_id_pk", + "entityType": "pks", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "columns": [ + "itemId", + "relatedItemId" + ], + "nameExplicit": true, + "name": "example_advanced_articles_categories_pk", + "entityType": "pks", + "schema": "public", + "table": "example_advanced_articles_categories" + }, + { + "columns": [ + "itemId", + "relatedItemId" + ], + "nameExplicit": true, + "name": "example_advanced_articles_related_articles_pk", + "entityType": "pks", + "schema": "public", + "table": "example_advanced_articles_related_articles" + }, + { + "columns": [ + "itemId", + "relatedItemId" + ], + "nameExplicit": true, + "name": "example_advanced_articles_tags_pk", + "entityType": "pks", + "schema": "public", + "table": "example_advanced_articles_tags" + }, + { + "columns": [ + "itemId", + "languageId" + ], + "nameExplicit": true, + "name": "example_advanced_articles_translations_item_id_language_id_pk", + "entityType": "pks", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "columns": [ + "itemId", + "relatedItemId" + ], + "nameExplicit": true, + "name": "example_articles_gallery_pk", + "entityType": "pks", + "schema": "public", + "table": "example_articles_gallery" + }, + { + "columns": [ + "itemId", + "relatedItemId" + ], + "nameExplicit": true, + "name": "example_events_gallery_pk", + "entityType": "pks", + "schema": "public", + "table": "example_events_gallery" + }, + { + "columns": [ + "itemId", + "relatedItemId" + ], + "nameExplicit": true, + "name": "example_events_related_events_pk", + "entityType": "pks", + "schema": "public", + "table": "example_events_related_events" + }, + { + "columns": [ + "itemId", + "relatedItemId" + ], + "nameExplicit": true, + "name": "example_events_tags_pk", + "entityType": "pks", + "schema": "public", + "table": "example_events_tags" + }, + { + "columns": [ + "itemId", + "languageId" + ], + "nameExplicit": true, + "name": "example_events_translations_item_id_language_id_pk", + "entityType": "pks", + "schema": "public", + "table": "example_events_translations" + }, + { + "columns": [ + "itemId", + "languageId" + ], + "nameExplicit": true, + "name": "example_localized_articles_translations_item_id_language_id_pk", + "entityType": "pks", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "columns": [ + "itemId", + "relatedItemId" + ], + "nameExplicit": true, + "name": "example_tags_related_tags_pk", + "entityType": "pks", + "schema": "public", + "table": "example_tags_related_tags" + }, + { + "columns": [ + "itemId", + "languageId" + ], + "nameExplicit": true, + "name": "example_tags_translations_item_id_language_id_pk", + "entityType": "pks", + "schema": "public", + "table": "example_tags_translations" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_admin_permissions_pkey", + "schema": "public", + "table": "core_admin_permissions", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_admin_sessions_pkey", + "schema": "public", + "table": "core_admin_sessions", + "entityType": "pks" + }, + { + "columns": [ + "actionKey" + ], + "nameExplicit": false, + "name": "core_ai_action_settings_pkey", + "schema": "public", + "table": "core_ai_action_settings", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_ai_budget_periods_pkey", + "schema": "public", + "table": "core_ai_budget_periods", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_ai_calls_pkey", + "schema": "public", + "table": "core_ai_calls", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_ai_cost_adjustments_pkey", + "schema": "public", + "table": "core_ai_cost_adjustments", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_ai_reservations_pkey", + "schema": "public", + "table": "core_ai_reservations", + "entityType": "pks" + }, + { + "columns": [ + "roleId" + ], + "nameExplicit": false, + "name": "core_ai_role_policies_pkey", + "schema": "public", + "table": "core_ai_role_policies", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_ai_runs_pkey", + "schema": "public", + "table": "core_ai_runs", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_ai_settings_pkey", + "schema": "public", + "table": "core_ai_settings", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_ai_translation_sources_pkey", + "schema": "public", + "table": "core_ai_translation_sources", + "entityType": "pks" + }, + { + "columns": [ + "userId" + ], + "nameExplicit": false, + "name": "core_ai_user_overrides_pkey", + "schema": "public", + "table": "core_ai_user_overrides", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_content_documents_pkey", + "schema": "public", + "table": "core_content_documents", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_content_drafts_pkey", + "schema": "public", + "table": "core_content_drafts", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_content_field_locks_pkey", + "schema": "public", + "table": "core_content_field_locks", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_content_file_refs_pkey", + "schema": "public", + "table": "core_content_file_refs", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_content_revisions_pkey", + "schema": "public", + "table": "core_content_revisions", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_content_schedules_pkey", + "schema": "public", + "table": "core_content_schedules", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_content_slug_history_pkey", + "schema": "public", + "table": "core_content_slug_history", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_cron_pkey", + "schema": "public", + "table": "core_cron", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_admin_dashboard_pkey", + "schema": "public", + "table": "core_admin_dashboard", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_files_pkey", + "schema": "public", + "table": "core_files", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_files_alt_pkey", + "schema": "public", + "table": "core_files_alt", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_files_alt_analysis_pkey", + "schema": "public", + "table": "core_files_alt_analysis", + "entityType": "pks" + }, + { + "columns": [ + "fileId" + ], + "nameExplicit": false, + "name": "core_files_alt_state_pkey", + "schema": "public", + "table": "core_files_alt_state", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_languages_pkey", + "schema": "public", + "table": "core_languages", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_languages_words_pkey", + "schema": "public", + "table": "core_languages_words", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_logs_pkey", + "schema": "public", + "table": "core_logs", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_moderators_permissions_pkey", + "schema": "public", + "table": "core_moderators_permissions", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_navigation_pkey", + "schema": "public", + "table": "core_navigation", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_notification_deliveries_pkey", + "schema": "public", + "table": "core_notification_deliveries", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_notification_events_pkey", + "schema": "public", + "table": "core_notification_events", + "entityType": "pks" + }, + { + "columns": [ + "key" + ], + "nameExplicit": false, + "name": "core_notification_settings_pkey", + "schema": "public", + "table": "core_notification_settings", + "entityType": "pks" + }, + { + "columns": [ + "userId" + ], + "nameExplicit": false, + "name": "core_notification_user_state_pkey", + "schema": "public", + "table": "core_notification_user_state", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_notifications_pkey", + "schema": "public", + "table": "core_notifications", + "entityType": "pks" + }, + { + "columns": [ + "pageId" + ], + "nameExplicit": false, + "name": "core_page_layouts_pkey", + "schema": "public", + "table": "core_page_layouts", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_users_passkey_challenges_pkey", + "schema": "public", + "table": "core_users_passkey_challenges", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_users_passkeys_pkey", + "schema": "public", + "table": "core_users_passkeys", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_queue_pkey", + "schema": "public", + "table": "core_queue", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_roles_pkey", + "schema": "public", + "table": "core_roles", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_search_index_pkey", + "schema": "public", + "table": "core_search_index", + "entityType": "pks" + }, + { + "columns": [ + "name" + ], + "nameExplicit": false, + "name": "core_secrets_pkey", + "schema": "public", + "table": "core_secrets", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_sessions_pkey", + "schema": "public", + "table": "core_sessions", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_sessions_known_devices_pkey", + "schema": "public", + "table": "core_sessions_known_devices", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_users_pkey", + "schema": "public", + "table": "core_users", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_users_confirm_emails_pkey", + "schema": "public", + "table": "core_users_confirm_emails", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_users_forgot_password_pkey", + "schema": "public", + "table": "core_users_forgot_password", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_users_sso_operations_pkey", + "schema": "public", + "table": "core_users_sso_operations", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "blog_categories_pkey", + "schema": "public", + "table": "blog_categories", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "blog_posts_pkey", + "schema": "public", + "table": "blog_posts", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "example_advanced_articles_pkey", + "schema": "public", + "table": "example_advanced_articles", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "example_advanced_articles_faq_pkey", + "schema": "public", + "table": "example_advanced_articles_faq", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "example_articles_pkey", + "schema": "public", + "table": "example_articles", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "example_categories_pkey", + "schema": "public", + "table": "example_categories", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "example_events_pkey", + "schema": "public", + "table": "example_events", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "example_events_sessions_pkey", + "schema": "public", + "table": "example_events_sessions", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "example_localized_articles_pkey", + "schema": "public", + "table": "example_localized_articles", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "example_pages_pkey", + "schema": "public", + "table": "example_pages", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "example_tags_pkey", + "schema": "public", + "table": "example_tags", + "entityType": "pks" + }, + { + "nameExplicit": true, + "columns": [ + "itemType", + "itemId", + "languageCode" + ], + "nullsNotDistinct": false, + "name": "core_search_index_item_unique", + "entityType": "uniques", + "schema": "public", + "table": "core_search_index" + }, + { + "nameExplicit": true, + "columns": [ + "providerId", + "providerAccountId" + ], + "nullsNotDistinct": false, + "name": "core_users_sso_provider_account_key", + "entityType": "uniques", + "schema": "public", + "table": "core_users_sso" + }, + { + "nameExplicit": true, + "columns": [ + "userId", + "providerId" + ], + "nullsNotDistinct": false, + "name": "core_users_sso_user_provider_key", + "entityType": "uniques", + "schema": "public", + "table": "core_users_sso" + }, + { + "nameExplicit": false, + "columns": [ + "token" + ], + "nullsNotDistinct": false, + "name": "core_admin_sessions_token_unique", + "schema": "public", + "table": "core_admin_sessions", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "nullsNotDistinct": false, + "name": "core_admin_dashboard_userId_unique", + "schema": "public", + "table": "core_admin_dashboard", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "key" + ], + "nullsNotDistinct": false, + "name": "core_files_key_unique", + "schema": "public", + "table": "core_files", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "code" + ], + "nullsNotDistinct": false, + "name": "core_languages_code_unique", + "schema": "public", + "table": "core_languages", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "idempotencyKey" + ], + "nullsNotDistinct": false, + "name": "core_notification_deliveries_idempotencyKey_key", + "schema": "public", + "table": "core_notification_deliveries", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "tokenHash" + ], + "nullsNotDistinct": false, + "name": "core_users_passkey_challenges_tokenHash_key", + "schema": "public", + "table": "core_users_passkey_challenges", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "credentialId" + ], + "nullsNotDistinct": false, + "name": "core_users_passkeys_credentialId_key", + "schema": "public", + "table": "core_users_passkeys", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "token" + ], + "nullsNotDistinct": false, + "name": "core_sessions_token_unique", + "schema": "public", + "table": "core_sessions", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "publicId" + ], + "nullsNotDistinct": false, + "name": "core_sessions_known_devices_publicId_unique", + "schema": "public", + "table": "core_sessions_known_devices", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "nameCode" + ], + "nullsNotDistinct": false, + "name": "core_users_nameCode_unique", + "schema": "public", + "table": "core_users", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "name" + ], + "nullsNotDistinct": false, + "name": "core_users_name_unique", + "schema": "public", + "table": "core_users", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "email" + ], + "nullsNotDistinct": false, + "name": "core_users_email_unique", + "schema": "public", + "table": "core_users", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "token" + ], + "nullsNotDistinct": false, + "name": "core_users_confirm_emails_token_unique", + "schema": "public", + "table": "core_users_confirm_emails", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "nullsNotDistinct": false, + "name": "core_users_forgot_password_userId_unique", + "schema": "public", + "table": "core_users_forgot_password", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "token" + ], + "nullsNotDistinct": false, + "name": "core_users_forgot_password_token_unique", + "schema": "public", + "table": "core_users_forgot_password", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "tokenHash" + ], + "nullsNotDistinct": false, + "name": "core_users_sso_operations_tokenHash_key", + "schema": "public", + "table": "core_users_sso_operations", + "entityType": "uniques" + }, + { + "value": "\"intent\" IN ('link', 'import', 'sync', 'preview')", + "name": "core_users_sso_operations_intent_check", + "entityType": "checks", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "value": "\"field\" IN ('avatar', 'firstName', 'lastName')", + "name": "core_users_sso_profile_sources_field_check", + "entityType": "checks", + "schema": "public", + "table": "core_users_sso_profile_sources" + } + ], + "renames": [] +} \ No newline at end of file diff --git a/apps/api/migrations/20261011120604_add_search_index_image/migration.sql b/apps/api/migrations/20261011120604_add_search_index_image/migration.sql new file mode 100644 index 0000000000..baed3d2a71 --- /dev/null +++ b/apps/api/migrations/20261011120604_add_search_index_image/migration.sql @@ -0,0 +1,3 @@ +ALTER TABLE "core_search_index" ADD COLUMN "imageId" integer;--> statement-breakpoint +CREATE INDEX "core_search_index_image_id_idx" ON "core_search_index" ("imageId");--> statement-breakpoint +ALTER TABLE "core_search_index" ADD CONSTRAINT "core_search_index_imageId_core_files_id_fkey" FOREIGN KEY ("imageId") REFERENCES "core_files"("id") ON DELETE SET NULL; \ No newline at end of file diff --git a/apps/api/migrations/20261011120604_add_search_index_image/snapshot.json b/apps/api/migrations/20261011120604_add_search_index_image/snapshot.json new file mode 100644 index 0000000000..44817394b4 --- /dev/null +++ b/apps/api/migrations/20261011120604_add_search_index_image/snapshot.json @@ -0,0 +1,17060 @@ +{ + "version": "8", + "dialect": "postgres", + "id": "27a31513-4f49-4cab-aa26-2538019a87d9", + "prevIds": [ + "a3b555f1-3780-4d7e-9714-a4fa88b4b704" + ], + "ddl": [ + { + "isRlsEnabled": true, + "name": "core_admin_permissions", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_admin_sessions", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_ai_action_settings", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_ai_budget_periods", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_ai_calls", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_ai_cost_adjustments", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_ai_reservations", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_ai_role_permissions", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_ai_role_policies", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_ai_runs", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_ai_settings", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_ai_translation_sources", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_ai_user_overrides", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_content_documents", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_content_drafts", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_content_field_locks", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_content_file_refs", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_content_revisions", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_content_schedules", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_content_slug_history", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_cron", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_admin_dashboard", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_files", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_files_alt", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_files_alt_analysis", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_files_alt_state", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_languages", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_languages_words", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_logs", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_moderators_permissions", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_navigation", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_notification_deliveries", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_notification_events", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_notification_receipts", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_notification_settings", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_notification_user_state", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_notifications", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_page_layouts", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users_passkey_challenges", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users_passkeys", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_queue", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_roles", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_search_index", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_secrets", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_sessions", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_sessions_known_devices", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users_confirm_emails", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users_forgot_password", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users_secondary_roles", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users_sso", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users_sso_operations", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users_sso_profile_sources", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "blog_categories", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "blog_categories_translations", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "blog_posts", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "blog_posts_author_id", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "blog_posts_category_id", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "blog_posts_translations", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_advanced_articles", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_advanced_articles_categories", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_advanced_articles_faq", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_advanced_articles_related_articles", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_advanced_articles_tags", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_advanced_articles_translations", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_articles", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_articles_gallery", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_categories", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_events", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_events_gallery", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_events_related_events", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_events_sessions", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_events_tags", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_events_translations", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_localized_articles", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_localized_articles_translations", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_pages", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_tags", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_tags_related_tags", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_tags_translations", + "entityType": "tables", + "schema": "public" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "roleId", + "entityType": "columns", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "protected", + "entityType": "columns", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "unrestricted", + "entityType": "columns", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "permissions", + "entityType": "columns", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "token", + "entityType": "columns", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "lastSeen", + "entityType": "columns", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expiresAt", + "entityType": "columns", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "deviceId", + "entityType": "columns", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "actionKey", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "enabled", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "modelId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "fallbackModelId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "maxInputCharacters", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "maxOutputTokens", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "timeoutMs", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "maxRetries", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "maxSteps", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "dailyLimit", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "instructions", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_ai_budget_periods" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "scopeKey", + "entityType": "columns", + "schema": "public", + "table": "core_ai_budget_periods" + }, + { + "type": "varchar(10)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "unit", + "entityType": "columns", + "schema": "public", + "table": "core_ai_budget_periods" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "periodStart", + "entityType": "columns", + "schema": "public", + "table": "core_ai_budget_periods" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "periodEnd", + "entityType": "columns", + "schema": "public", + "table": "core_ai_budget_periods" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "limitAmount", + "entityType": "columns", + "schema": "public", + "table": "core_ai_budget_periods" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'0'", + "generated": null, + "identity": null, + "name": "spentAmount", + "entityType": "columns", + "schema": "public", + "table": "core_ai_budget_periods" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'0'", + "generated": null, + "identity": null, + "name": "reservedAmount", + "entityType": "columns", + "schema": "public", + "table": "core_ai_budget_periods" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "unknownCount", + "entityType": "columns", + "schema": "public", + "table": "core_ai_budget_periods" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_budget_periods" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "runId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "attempt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "modelId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "provider", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerModelId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerRequestId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "errorCode", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "inputTokens", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "outputTokens", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "cacheReadTokens", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "cacheWriteTokens", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "reasoningTokens", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "images", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "costUsd", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "costSource", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "costReason", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pricingVersion", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pricingSnapshot", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "startedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "finishedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "reconciledAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "callId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "runId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "previousCostUsd", + "entityType": "columns", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "previousSource", + "entityType": "columns", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "newCostUsd", + "entityType": "columns", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "newSource", + "entityType": "columns", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "deltaUsd", + "entityType": "columns", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "reason", + "entityType": "columns", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_ai_reservations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "runId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_reservations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "budgetPeriodId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_reservations" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "amount", + "entityType": "columns", + "schema": "public", + "table": "core_ai_reservations" + }, + { + "type": "varchar(10)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'active'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "core_ai_reservations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_reservations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "settledAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_reservations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "roleId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_role_permissions" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "permission", + "entityType": "columns", + "schema": "public", + "table": "core_ai_role_permissions" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "granted", + "entityType": "columns", + "schema": "public", + "table": "core_ai_role_permissions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "dailyLimit", + "entityType": "columns", + "schema": "public", + "table": "core_ai_role_permissions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "roleId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_role_policies" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "monthlyPoints", + "entityType": "columns", + "schema": "public", + "table": "core_ai_role_policies" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "unlimited", + "entityType": "columns", + "schema": "public", + "table": "core_ai_role_policies" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_role_policies" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "actionKey", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(10)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "actorType", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "idempotencyKey", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "resourceType", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "resourceId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "errorCode", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "promptVersion", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pointsConversionVersion", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "modelId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "provider", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerModelId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "inputTokens", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "outputTokens", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "cacheReadTokens", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "cacheWriteTokens", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "reasoningTokens", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "costUsd", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "costSource", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "chargedUsd", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "chargedPoints", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'0'", + "generated": null, + "identity": null, + "name": "reservedUsd", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'0'", + "generated": null, + "identity": null, + "name": "reservedPoints", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'pending'", + "generated": null, + "identity": null, + "name": "settlement", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "leaseExpiresAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "accepted", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "acceptedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "sourceFingerprint", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "startedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "finishedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "settledAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "enabled", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "monthlyBudgetUsd", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "systemMonthlyBudgetUsd", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'0'", + "generated": null, + "identity": null, + "name": "defaultMonthlyPoints", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "pointsConversionVersion", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "10", + "generated": null, + "identity": null, + "name": "userRequestsPerMinute", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "2", + "generated": null, + "identity": null, + "name": "userConcurrency", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "2", + "generated": null, + "identity": null, + "name": "systemConcurrency", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "365", + "generated": null, + "identity": null, + "name": "historyRetentionDays", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "altEnabled", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "10", + "generated": null, + "identity": null, + "name": "altBatchSize", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "altScanCursor", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "altScanStartedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_ai_translation_sources" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "contentTypeId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_translation_sources" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_translation_sources" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "locale", + "entityType": "columns", + "schema": "public", + "table": "core_ai_translation_sources" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "field", + "entityType": "columns", + "schema": "public", + "table": "core_ai_translation_sources" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "sourceLocale", + "entityType": "columns", + "schema": "public", + "table": "core_ai_translation_sources" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "sourceFingerprint", + "entityType": "columns", + "schema": "public", + "table": "core_ai_translation_sources" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "targetFingerprint", + "entityType": "columns", + "schema": "public", + "table": "core_ai_translation_sources" + }, + { + "type": "varchar(10)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "origin", + "entityType": "columns", + "schema": "public", + "table": "core_ai_translation_sources" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updatedById", + "entityType": "columns", + "schema": "public", + "table": "core_ai_translation_sources" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_translation_sources" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_user_overrides" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "monthlyPoints", + "entityType": "columns", + "schema": "public", + "table": "core_ai_user_overrides" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "unlimited", + "entityType": "columns", + "schema": "public", + "table": "core_ai_user_overrides" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "blocked", + "entityType": "columns", + "schema": "public", + "table": "core_ai_user_overrides" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_user_overrides" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_content_documents" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "contentTypeId", + "entityType": "columns", + "schema": "public", + "table": "core_content_documents" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "core_content_documents" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "field", + "entityType": "columns", + "schema": "public", + "table": "core_content_documents" + }, + { + "type": "varchar(35)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "language", + "entityType": "columns", + "schema": "public", + "table": "core_content_documents" + }, + { + "type": "bytea", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "state", + "entityType": "columns", + "schema": "public", + "table": "core_content_documents" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "baseVersion", + "entityType": "columns", + "schema": "public", + "table": "core_content_documents" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_documents" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updatedBy", + "entityType": "columns", + "schema": "public", + "table": "core_content_documents" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_content_drafts" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "contentTypeId", + "entityType": "columns", + "schema": "public", + "table": "core_content_drafts" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "core_content_drafts" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "language", + "entityType": "columns", + "schema": "public", + "table": "core_content_drafts" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "values", + "entityType": "columns", + "schema": "public", + "table": "core_content_drafts" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "baseVersion", + "entityType": "columns", + "schema": "public", + "table": "core_content_drafts" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_drafts" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updatedBy", + "entityType": "columns", + "schema": "public", + "table": "core_content_drafts" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_content_field_locks" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "contentTypeId", + "entityType": "columns", + "schema": "public", + "table": "core_content_field_locks" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "core_content_field_locks" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "field", + "entityType": "columns", + "schema": "public", + "table": "core_content_field_locks" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "language", + "entityType": "columns", + "schema": "public", + "table": "core_content_field_locks" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_content_field_locks" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "acquiredAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_field_locks" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expiresAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_field_locks" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_content_file_refs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "revisionId", + "entityType": "columns", + "schema": "public", + "table": "core_content_file_refs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "fileId", + "entityType": "columns", + "schema": "public", + "table": "core_content_file_refs" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_file_refs" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "contentTypeId", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "languageId", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "operation", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "snapshot", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "changedFields", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'system'", + "generated": null, + "identity": null, + "name": "actorType", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "actorUserId", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "restoredFromRevisionId", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "contentTypeId", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "action", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "scheduledFor", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "generation", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'pending'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "createdBy", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "completedAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastError", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "effectsError", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "contentTypeId", + "entityType": "columns", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "languageId", + "entityType": "columns", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "type": "varchar(512)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "path", + "entityType": "columns", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "retiredAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_cron" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "core_cron" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "description", + "entityType": "columns", + "schema": "public", + "table": "core_cron" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastRun", + "entityType": "columns", + "schema": "public", + "table": "core_cron" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_cron" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_cron" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "module", + "entityType": "columns", + "schema": "public", + "table": "core_cron" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "nextRun", + "entityType": "columns", + "schema": "public", + "table": "core_cron" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "schedule", + "entityType": "columns", + "schema": "public", + "table": "core_cron" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_admin_dashboard" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_admin_dashboard" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "widgets", + "entityType": "columns", + "schema": "public", + "table": "core_admin_dashboard" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_admin_dashboard" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_admin_dashboard" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "varchar(512)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "key", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "folder", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "mimeType", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "size", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "metadata", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "fingerprint", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'automatic'", + "generated": null, + "identity": null, + "name": "altPolicy", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "fileId", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "languageCode", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "text", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt" + }, + { + "type": "varchar(10)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "origin", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "fileFingerprint", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "runId", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updatedById", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt_analysis" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "fileId", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt_analysis" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "fileFingerprint", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt_analysis" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "description", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt_analysis" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "runId", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt_analysis" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt_analysis" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "fileId", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt_state" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt_state" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastError", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt_state" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt_state" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_languages" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "code", + "entityType": "columns", + "schema": "public", + "table": "core_languages" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "core_languages" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'UTC'", + "generated": null, + "identity": null, + "name": "timezone", + "entityType": "columns", + "schema": "public", + "table": "core_languages" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "protected", + "entityType": "columns", + "schema": "public", + "table": "core_languages" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "default", + "entityType": "columns", + "schema": "public", + "table": "core_languages" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_languages" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_languages" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "time24", + "entityType": "columns", + "schema": "public", + "table": "core_languages" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_languages_words" + }, + { + "type": "varchar", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "languageCode", + "entityType": "columns", + "schema": "public", + "table": "core_languages_words" + }, + { + "type": "varchar(50)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginCode", + "entityType": "columns", + "schema": "public", + "table": "core_languages_words" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "core_languages_words" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "value", + "entityType": "columns", + "schema": "public", + "table": "core_languages_words" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "tableName", + "entityType": "columns", + "schema": "public", + "table": "core_languages_words" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "variable", + "entityType": "columns", + "schema": "public", + "table": "core_languages_words" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "varchar(10)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "type", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "content", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "varchar(45)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ipAddress", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "varchar(10)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'GET'", + "generated": null, + "identity": null, + "name": "method", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'localhost'", + "generated": null, + "identity": null, + "name": "path", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userAgent", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "500", + "generated": null, + "identity": null, + "name": "statusCode", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "roleId", + "entityType": "columns", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "protected", + "entityType": "columns", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "unrestricted", + "entityType": "columns", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "permissions", + "entityType": "columns", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "parentId", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'header'", + "generated": null, + "identity": null, + "name": "location", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "kind", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "varchar(50)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "varchar(120)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "presetId", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "href", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "icon", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "isOpenInNewTab", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "bigserial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "channel", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "mode", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "eventId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "idempotencyKey", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'pending'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "attempts", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "5", + "generated": null, + "identity": null, + "name": "maxAttempts", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "availableAt", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "periodStart", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "periodEnd", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "itemCount", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerMessageId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "varchar(500)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastError", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "varchar(50)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "skipReason", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "sentAt", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "bigserial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "type", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "schemaVersion", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "idempotencyKey", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "actorId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "subjectType", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "subjectId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "groupKey", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "data", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 1, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "recipientIds", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "allowSelf", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'pending'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "recipientCursor", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "deliveredCount", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastError", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "completedAt", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "eventId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "notificationId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "seq", + "entityType": "columns", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "emailPending", + "entityType": "columns", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "emailDeliveryId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "key", + "entityType": "columns", + "schema": "public", + "table": "core_notification_settings" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "value", + "entityType": "columns", + "schema": "public", + "table": "core_notification_settings" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_notification_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_user_state" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "unreadCount", + "entityType": "columns", + "schema": "public", + "table": "core_notification_user_state" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "revision", + "entityType": "columns", + "schema": "public", + "table": "core_notification_user_state" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "preferences", + "entityType": "columns", + "schema": "public", + "table": "core_notification_user_state" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_notification_user_state" + }, + { + "type": "bigserial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "type", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "varchar(50)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "category", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "groupKey", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "groupBucket", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "subjectType", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "subjectId", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "latestEventId", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "eventCount", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "activitySeq", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "readSeq", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "readAt", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "archivedAt", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "lastActivityAt", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "varchar(120)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pageId", + "entityType": "columns", + "schema": "public", + "table": "core_page_layouts" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "zones", + "entityType": "columns", + "schema": "public", + "table": "core_page_layouts" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_page_layouts" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_page_layouts" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "tokenHash", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ceremony", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "varchar(128)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "challenge", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "varchar(128)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "webauthnUserId", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expiresAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "varchar(1024)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "credentialId", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publicKey", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "counter", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "varchar(128)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "webauthnUserId", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 1, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "transports", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "deviceType", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "backedUp", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "varchar(36)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "aaguid", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastUsedAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'default'", + "generated": null, + "identity": null, + "name": "queue", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'pending'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "payload", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "priority", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "attempts", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "3", + "generated": null, + "identity": null, + "name": "maxAttempts", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "availableAt", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "reservedAt", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastError", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "completedAt", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "dedupeKey", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "protected", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "default", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "root", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "guest", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "varchar(50)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "color", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "prefix", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "allowUploadFiles", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "totalMaxStorage", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "maxStorageForSubmit", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "allowUploadAvatar", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "2048", + "generated": null, + "identity": null, + "name": "maxAvatarSize", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "allowUploadCover", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "5120", + "generated": null, + "identity": null, + "name": "maxCoverSize", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "allowEditPersonalInfo", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemType", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "languageCode", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 1, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "authorIds", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "content", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "tsvector", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": { + "as": "setweight(to_tsvector(CASE lower(split_part(\"core_search_index\".\"languageCode\", '-', 1)) WHEN 'da' THEN 'danish'::regconfig WHEN 'de' THEN 'german'::regconfig WHEN 'en' THEN 'english'::regconfig WHEN 'es' THEN 'spanish'::regconfig WHEN 'fi' THEN 'finnish'::regconfig WHEN 'fr' THEN 'french'::regconfig WHEN 'hu' THEN 'hungarian'::regconfig WHEN 'it' THEN 'italian'::regconfig WHEN 'nl' THEN 'dutch'::regconfig WHEN 'no' THEN 'norwegian'::regconfig WHEN 'pl' THEN 'polish'::regconfig WHEN 'pt' THEN 'portuguese'::regconfig WHEN 'ro' THEN 'romanian'::regconfig WHEN 'ru' THEN 'russian'::regconfig WHEN 'sv' THEN 'swedish'::regconfig WHEN 'tr' THEN 'turkish'::regconfig ELSE 'simple'::regconfig END, coalesce(\"core_search_index\".\"title\", '')), 'A') || setweight(to_tsvector(CASE lower(split_part(\"core_search_index\".\"languageCode\", '-', 1)) WHEN 'da' THEN 'danish'::regconfig WHEN 'de' THEN 'german'::regconfig WHEN 'en' THEN 'english'::regconfig WHEN 'es' THEN 'spanish'::regconfig WHEN 'fi' THEN 'finnish'::regconfig WHEN 'fr' THEN 'french'::regconfig WHEN 'hu' THEN 'hungarian'::regconfig WHEN 'it' THEN 'italian'::regconfig WHEN 'nl' THEN 'dutch'::regconfig WHEN 'no' THEN 'norwegian'::regconfig WHEN 'pl' THEN 'polish'::regconfig WHEN 'pt' THEN 'portuguese'::regconfig WHEN 'ro' THEN 'romanian'::regconfig WHEN 'ru' THEN 'russian'::regconfig WHEN 'sv' THEN 'swedish'::regconfig WHEN 'tr' THEN 'turkish'::regconfig ELSE 'simple'::regconfig END, coalesce(\"core_search_index\".\"content\", '')), 'B')", + "type": "stored" + }, + "identity": null, + "name": "search_vector", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "containerType", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "containerId", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "url", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "isPublic", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "isHidden", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "imageId", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "metadata", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "indexedAt", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "core_secrets" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "value", + "entityType": "columns", + "schema": "public", + "table": "core_secrets" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_secrets" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_sessions" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "token", + "entityType": "columns", + "schema": "public", + "table": "core_sessions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_sessions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_sessions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expiresAt", + "entityType": "columns", + "schema": "public", + "table": "core_sessions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "deviceId", + "entityType": "columns", + "schema": "public", + "table": "core_sessions" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_sessions_known_devices" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publicId", + "entityType": "columns", + "schema": "public", + "table": "core_sessions_known_devices" + }, + { + "type": "varchar(40)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ipAddress", + "entityType": "columns", + "schema": "public", + "table": "core_sessions_known_devices" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userAgent", + "entityType": "columns", + "schema": "public", + "table": "core_sessions_known_devices" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "lastSeen", + "entityType": "columns", + "schema": "public", + "table": "core_sessions_known_devices" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "nameCode", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "email", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(128)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "firstName", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(128)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastName", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "phone", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "headline", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "showRealName", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "password", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "newsletter", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(6)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "avatarColor", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "emailVerified", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "roleId", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "birthday", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "timeZone", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(40)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ipAddress", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'en'", + "generated": null, + "identity": null, + "name": "language", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "avatarId", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "coverId", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_users_confirm_emails" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_users_confirm_emails" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "token", + "entityType": "columns", + "schema": "public", + "table": "core_users_confirm_emails" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_confirm_emails" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expiresAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_confirm_emails" + }, + { + "type": "varchar(40)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ipAddress", + "entityType": "columns", + "schema": "public", + "table": "core_users_confirm_emails" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_users_forgot_password" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_users_forgot_password" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "token", + "entityType": "columns", + "schema": "public", + "table": "core_users_forgot_password" + }, + { + "type": "varchar(40)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ipAddress", + "entityType": "columns", + "schema": "public", + "table": "core_users_forgot_password" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_forgot_password" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expiresAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_forgot_password" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_users_secondary_roles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "roleId", + "entityType": "columns", + "schema": "public", + "table": "core_users_secondary_roles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_secondary_roles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerAccountId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerEmail", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerUsername", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "syncOnSignIn", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "varchar(2048)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "avatarSourceUrl", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "avatarSha256", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "avatarFileId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "tokenHash", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "intent", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 1, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "fields", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerAccountId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "preview", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expiresAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_profile_sources" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "field", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_profile_sources" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_profile_sources" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_profile_sources" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "blog_categories" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "blog_categories" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "blog_categories" + }, + { + "type": "varchar(50)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "color", + "entityType": "columns", + "schema": "public", + "table": "blog_categories" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "languageId", + "entityType": "columns", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "blog_posts" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "blog_posts" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "blog_posts" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hiddenAt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hiddenBy", + "entityType": "columns", + "schema": "public", + "table": "blog_posts" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "coverImage", + "entityType": "columns", + "schema": "public", + "table": "blog_posts" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "relatedItemId", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "relatedItemId", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "languageId", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "friendlyUrl", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "content", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "excerpt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "coverImageAlt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "syndicationIndexable", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "syndicationNoIndex", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "5", + "generated": null, + "identity": null, + "name": "syndicationPriority", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_categories" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "relatedItemId", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_categories" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_categories" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_categories" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_faq" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_faq" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_faq" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_faq" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_faq" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "question", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_faq" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "answer", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_faq" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_related_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "relatedItemId", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_related_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_related_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_related_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_tags" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "relatedItemId", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_tags" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_tags" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_tags" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "languageId", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "seoTitle", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "seoDescription", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "code", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "excerpt", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "views", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "featured", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "noIndex", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "author", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "animation", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "category", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "example_articles_gallery" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "relatedItemId", + "entityType": "columns", + "schema": "public", + "table": "example_articles_gallery" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "example_articles_gallery" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_articles_gallery" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "example_categories" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_categories" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_categories" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "example_categories" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": { + "type": "byDefault", + "name": "example_events_id_seq", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": 1, + "cycle": false + }, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "example_events" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_events" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_events" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "example_events" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "example_events" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "example_events" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hiddenAt", + "entityType": "columns", + "schema": "public", + "table": "example_events" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hiddenBy", + "entityType": "columns", + "schema": "public", + "table": "example_events" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "primaryTag", + "entityType": "columns", + "schema": "public", + "table": "example_events" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "category", + "entityType": "columns", + "schema": "public", + "table": "example_events" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "example_events_gallery" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "relatedItemId", + "entityType": "columns", + "schema": "public", + "table": "example_events_gallery" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "example_events_gallery" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_events_gallery" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "example_events_related_events" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "relatedItemId", + "entityType": "columns", + "schema": "public", + "table": "example_events_related_events" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "example_events_related_events" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_events_related_events" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "example_events_sessions" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "example_events_sessions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "example_events_sessions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_events_sessions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_events_sessions" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "example_events_sessions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "startsAt", + "entityType": "columns", + "schema": "public", + "table": "example_events_sessions" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "example_events_tags" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "relatedItemId", + "entityType": "columns", + "schema": "public", + "table": "example_events_tags" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "example_events_tags" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_events_tags" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "example_events_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "languageId", + "entityType": "columns", + "schema": "public", + "table": "example_events_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "example_events_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_events_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_events_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "example_events_translations" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "example_events_translations" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "example_events_translations" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "example_events_translations" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "body", + "entityType": "columns", + "schema": "public", + "table": "example_events_translations" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "featured", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "languageId", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "body", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "example_pages" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_pages" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_pages" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "example_pages" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "example_pages" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "example_pages" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "example_pages" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "content", + "entityType": "columns", + "schema": "public", + "table": "example_pages" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "example_tags" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_tags" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_tags" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "example_tags" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "example_tags" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "example_tags" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hiddenAt", + "entityType": "columns", + "schema": "public", + "table": "example_tags" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hiddenBy", + "entityType": "columns", + "schema": "public", + "table": "example_tags" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "category", + "entityType": "columns", + "schema": "public", + "table": "example_tags" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "example_tags_related_tags" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "relatedItemId", + "entityType": "columns", + "schema": "public", + "table": "example_tags_related_tags" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "example_tags_related_tags" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_tags_related_tags" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "example_tags_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "languageId", + "entityType": "columns", + "schema": "public", + "table": "example_tags_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "example_tags_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_tags_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_tags_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "example_tags_translations" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "example_tags_translations" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "example_tags_translations" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "example_tags_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "roleId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_admin_permissions_role_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_admin_permissions_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_admin_permissions_updated_at_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_admin_sessions_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "deviceId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_admin_sessions_device_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "scopeKey", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "periodStart", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_ai_budget_periods_scope_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_ai_budget_periods" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "runId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_ai_calls_run_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_ai_calls" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "costSource", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "reconciledAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_ai_calls_reconcile_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_ai_calls" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "callId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_ai_cost_adjustments_call_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "runId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "budgetPeriodId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_ai_reservations_run_period_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_ai_reservations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_ai_reservations_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_ai_reservations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_ai_runs_user_created_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_ai_runs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_ai_runs_created_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_ai_runs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "actionKey", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_ai_runs_action_created_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_ai_runs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "leaseExpiresAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_ai_runs_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_ai_runs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "actorType", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "idempotencyKey", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": "\"idempotencyKey\" IS NOT NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_ai_runs_idempotency_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_ai_runs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "locale", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "field", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_ai_translation_sources_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_ai_translation_sources" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "field", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "language", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_documents_doc_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_content_documents" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_documents_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_documents" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedBy", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_documents_updated_by_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_documents" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "language", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_drafts_key_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_content_drafts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_drafts_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_drafts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedBy", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_drafts_updated_by_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_drafts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "field", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "language", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_field_locks_key_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_content_field_locks" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "expiresAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_field_locks_expires_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_field_locks" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_field_locks_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_field_locks" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "revisionId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "fileId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_file_refs_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_content_file_refs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "fileId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_file_refs_file_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_file_refs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "version", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": "\"languageId\" IS NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_revisions_item_version_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_content_revisions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "version", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": "\"languageId\" IS NOT NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_revisions_translation_version_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_content_revisions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "version", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_revisions_language_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_revisions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "pluginId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_revisions_plugin_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_revisions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "actorUserId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_revisions_actor_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_revisions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "action", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": "status = 'pending'", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_schedules_active_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_content_schedules" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "scheduledFor", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_schedules_due_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_schedules" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_schedules_item_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_schedules" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "pluginId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_schedules_plugin_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_schedules" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdBy", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_schedules_created_by_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_schedules" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": "\"languageId\" IS NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_slug_history_shared_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": "\"languageId\" IS NOT NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_slug_history_locale_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_slug_history_item_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "pluginId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_slug_history_plugin_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "lastRun", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_cron_last_run_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_cron" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_admin_dashboard_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_admin_dashboard" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_files_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_files" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_files_created_at_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_files" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "fileId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "languageCode", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_files_alt_file_language_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_files_alt" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "fileId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "fileFingerprint", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_files_alt_analysis_file_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_files_alt_analysis" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": "status <> 'completed'", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_files_alt_state_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_files_alt_state" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "name", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_languages_name_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_languages" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageCode", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_languages_words_lang_code_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_languages_words" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "tableName", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "pluginCode", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "variable", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_languages_words_lookup_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_languages_words" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_logs_created_at_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_logs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_logs_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_logs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "roleId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_moderators_permissions_role_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_moderators_permissions_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_moderators_permissions_updated_at_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_navigation_position_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_navigation" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "location", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_navigation_location_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_navigation" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "parentId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_navigation_parent_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_navigation" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "availableAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": "status = 'pending'", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_deliveries_claim_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_deliveries_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_deliveries_user_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "pluginId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "type", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "idempotencyKey", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_events_idempotency_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_events_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_events_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "notificationId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "seq", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_receipts_notification_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_receipts_user_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": "\"emailPending\" = true AND \"emailDeliveryId\" IS NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_receipts_email_pending_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "unreadCount", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_user_state_unread_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_user_state" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "pluginId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "type", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "groupKey", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "groupBucket", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notifications_group_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_notifications" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "lastActivityAt", + "isExpression": false, + "asc": false, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": false, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": "\"archivedAt\" IS NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notifications_inbox_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notifications" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "lastActivityAt", + "isExpression": false, + "asc": false, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": false, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": "\"archivedAt\" IS NULL AND \"readSeq\" < \"activitySeq\"", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notifications_unread_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notifications" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "subjectType", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "subjectId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notifications_subject_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notifications" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "lastActivityAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notifications_last_activity_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notifications" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "latestEventId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notifications_latest_event_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notifications" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "expiresAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_passkey_challenges_expires_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_passkey_challenges_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_passkeys_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "webauthnUserId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_passkeys_webauthn_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "availableAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_queue_status_available_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_queue" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_queue_created_at_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_queue" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "pluginId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "dedupeKey", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": "\"dedupeKey\" IS NOT NULL AND status IN ('pending', 'processing')", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_queue_dedupe_active_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_queue" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_roles_updated_at_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_roles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "search_vector", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "gin", + "concurrently": false, + "name": "core_search_index_search_vector_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_search_index" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_search_index_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_search_index" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "authorIds", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "gin", + "concurrently": false, + "name": "core_search_index_author_ids_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_search_index" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemType", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_search_index_item_type_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_search_index" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageCode", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_search_index_language_code_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_search_index" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "isPublic", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_search_index_is_public_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_search_index" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "imageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_search_index_image_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_search_index" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_sessions_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_sessions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "deviceId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_sessions_device_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_sessions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "ipAddress", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_sessions_known_devices_ip_address_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_sessions_known_devices" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "avatarId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_avatar_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "coverId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_cover_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_created_at_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "roleId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_secondary_roles_role_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_secondary_roles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_sso_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_sso" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "providerId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_sso_operations_user_provider_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "expiresAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_sso_operations_expires_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_categories_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_categories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_categories_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_categories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_categories_translations_language_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_status_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "coverImage", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_cover_image_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_status_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "hiddenBy", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_hidden_by_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_author_id_position_key", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "relatedItemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_author_id_related_item_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_category_id_position_key", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "relatedItemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_category_id_related_item_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_translations_language_id_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "friendlyUrl", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_translations_language_id_friendly_url_key", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "syndicationPriority", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_syndication_priority_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_status_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_categories_position_key", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles_categories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "relatedItemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_categories_related_item_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles_categories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_faq_position_key", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles_faq" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_related_articles_position_key", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles_related_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "relatedItemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_related_articles_related_item_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles_related_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_tags_position_key", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles_tags" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "relatedItemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_tags_related_item_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles_tags" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_translations_language_id_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_translations_language_id_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_status_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "code", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_code_key", + "entityType": "indexes", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "author", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_author_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "animation", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_animation_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "category", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_category_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_status_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_gallery_position_key", + "entityType": "indexes", + "schema": "public", + "table": "example_articles_gallery" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "relatedItemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_gallery_related_item_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_articles_gallery" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_categories_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_categories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_categories_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_categories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "primaryTag", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_events_primary_tag_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "category", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_events_category_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_events_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_events_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_events_status_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "hiddenBy", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_events_hidden_by_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_events_gallery_position_key", + "entityType": "indexes", + "schema": "public", + "table": "example_events_gallery" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "relatedItemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_events_gallery_related_item_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_events_gallery" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_events_related_events_position_key", + "entityType": "indexes", + "schema": "public", + "table": "example_events_related_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "relatedItemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_events_related_events_related_item_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_events_related_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_events_sessions_position_key", + "entityType": "indexes", + "schema": "public", + "table": "example_events_sessions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_events_tags_position_key", + "entityType": "indexes", + "schema": "public", + "table": "example_events_tags" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "relatedItemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_events_tags_related_item_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_events_tags" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_events_translations_language_id_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_events_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_events_translations_language_id_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "example_events_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_localized_articles_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_localized_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_localized_articles_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_localized_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_localized_articles_status_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_localized_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_localized_articles_translations_language_id_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_localized_articles_translations_language_id_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_pages_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "example_pages" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_pages_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_pages" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_pages_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_pages" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_pages_status_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_pages" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "category", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_tags_category_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_tags" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_tags_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_tags" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_tags_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_tags" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_tags_status_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_tags" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "hiddenBy", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_tags_hidden_by_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_tags" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_tags_related_tags_position_key", + "entityType": "indexes", + "schema": "public", + "table": "example_tags_related_tags" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "relatedItemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_tags_related_tags_related_item_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_tags_related_tags" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_tags_translations_language_id_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_tags_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_tags_translations_language_id_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "example_tags_translations" + }, + { + "nameExplicit": false, + "columns": [ + "roleId" + ], + "schemaTo": "public", + "tableTo": "core_roles", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_admin_permissions_roleId_core_roles_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_admin_permissions_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_admin_sessions_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "nameExplicit": false, + "columns": [ + "deviceId" + ], + "schemaTo": "public", + "tableTo": "core_sessions_known_devices", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_admin_sessions_deviceId_core_sessions_known_devices_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "nameExplicit": false, + "columns": [ + "runId" + ], + "schemaTo": "public", + "tableTo": "core_ai_runs", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_ai_calls_runId_core_ai_runs_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_ai_calls" + }, + { + "nameExplicit": false, + "columns": [ + "callId" + ], + "schemaTo": "public", + "tableTo": "core_ai_calls", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_ai_cost_adjustments_callId_core_ai_calls_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "nameExplicit": false, + "columns": [ + "runId" + ], + "schemaTo": "public", + "tableTo": "core_ai_runs", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_ai_cost_adjustments_runId_core_ai_runs_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "nameExplicit": false, + "columns": [ + "runId" + ], + "schemaTo": "public", + "tableTo": "core_ai_runs", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_ai_reservations_runId_core_ai_runs_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_ai_reservations" + }, + { + "nameExplicit": false, + "columns": [ + "budgetPeriodId" + ], + "schemaTo": "public", + "tableTo": "core_ai_budget_periods", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_ai_reservations_FClYpPXZwvsm_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_ai_reservations" + }, + { + "nameExplicit": false, + "columns": [ + "roleId" + ], + "schemaTo": "public", + "tableTo": "core_roles", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_ai_role_permissions_roleId_core_roles_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_ai_role_permissions" + }, + { + "nameExplicit": false, + "columns": [ + "roleId" + ], + "schemaTo": "public", + "tableTo": "core_roles", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_ai_role_policies_roleId_core_roles_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_ai_role_policies" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_ai_runs_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_ai_runs" + }, + { + "nameExplicit": false, + "columns": [ + "updatedById" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_ai_translation_sources_updatedById_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_ai_translation_sources" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_ai_user_overrides_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_ai_user_overrides" + }, + { + "nameExplicit": false, + "columns": [ + "updatedBy" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "core_content_documents_updatedBy_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_content_documents" + }, + { + "nameExplicit": false, + "columns": [ + "updatedBy" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "core_content_drafts_updatedBy_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_content_drafts" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "core_content_field_locks_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_content_field_locks" + }, + { + "nameExplicit": false, + "columns": [ + "revisionId" + ], + "schemaTo": "public", + "tableTo": "core_content_revisions", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "core_content_file_refs_5get6SfhBPHr_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_content_file_refs" + }, + { + "nameExplicit": false, + "columns": [ + "fileId" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "core_content_file_refs_fileId_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_content_file_refs" + }, + { + "nameExplicit": false, + "columns": [ + "actorUserId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "core_content_revisions_actorUserId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_content_revisions" + }, + { + "nameExplicit": false, + "columns": [ + "createdBy" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "core_content_schedules_createdBy_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_content_schedules" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_admin_dashboard_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_admin_dashboard" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_files_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_files" + }, + { + "nameExplicit": false, + "columns": [ + "fileId" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_files_alt_fileId_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_files_alt" + }, + { + "nameExplicit": false, + "columns": [ + "languageCode" + ], + "schemaTo": "public", + "tableTo": "core_languages", + "columnsTo": [ + "code" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_files_alt_languageCode_core_languages_code_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_files_alt" + }, + { + "nameExplicit": false, + "columns": [ + "runId" + ], + "schemaTo": "public", + "tableTo": "core_ai_runs", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_files_alt_runId_core_ai_runs_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_files_alt" + }, + { + "nameExplicit": false, + "columns": [ + "updatedById" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_files_alt_updatedById_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_files_alt" + }, + { + "nameExplicit": false, + "columns": [ + "fileId" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_files_alt_analysis_fileId_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_files_alt_analysis" + }, + { + "nameExplicit": false, + "columns": [ + "runId" + ], + "schemaTo": "public", + "tableTo": "core_ai_runs", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_files_alt_analysis_runId_core_ai_runs_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_files_alt_analysis" + }, + { + "nameExplicit": false, + "columns": [ + "fileId" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_files_alt_state_fileId_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_files_alt_state" + }, + { + "nameExplicit": false, + "columns": [ + "languageCode" + ], + "schemaTo": "public", + "tableTo": "core_languages", + "columnsTo": [ + "code" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_languages_words_languageCode_core_languages_code_fk", + "entityType": "fks", + "schema": "public", + "table": "core_languages_words" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "core_logs_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_logs" + }, + { + "nameExplicit": false, + "columns": [ + "roleId" + ], + "schemaTo": "public", + "tableTo": "core_roles", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_moderators_permissions_roleId_core_roles_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_moderators_permissions_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "nameExplicit": false, + "columns": [ + "parentId" + ], + "schemaTo": "public", + "tableTo": "core_navigation", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_navigation_parentId_core_navigation_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_navigation" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_notification_deliveries_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "nameExplicit": false, + "columns": [ + "eventId" + ], + "schemaTo": "public", + "tableTo": "core_notification_events", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_notification_deliveries_vcf37BytIa0M_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "nameExplicit": false, + "columns": [ + "actorId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_notification_events_actorId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_notification_events" + }, + { + "nameExplicit": false, + "columns": [ + "eventId" + ], + "schemaTo": "public", + "tableTo": "core_notification_events", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_notification_receipts_UQ7ZEs0I6ijx_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_notification_receipts_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "nameExplicit": false, + "columns": [ + "notificationId" + ], + "schemaTo": "public", + "tableTo": "core_notifications", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_notification_receipts_Vxye2Cs6MgcL_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_notification_user_state_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_notification_user_state" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_notifications_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_notifications" + }, + { + "nameExplicit": false, + "columns": [ + "latestEventId" + ], + "schemaTo": "public", + "tableTo": "core_notification_events", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "RESTRICT", + "name": "core_notifications_I8QoGbTal5Wt_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_notifications" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_users_passkey_challenges_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_users_passkeys_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "nameExplicit": false, + "columns": [ + "imageId" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_search_index_imageId_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_search_index" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_sessions_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_sessions" + }, + { + "nameExplicit": false, + "columns": [ + "deviceId" + ], + "schemaTo": "public", + "tableTo": "core_sessions_known_devices", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_sessions_deviceId_core_sessions_known_devices_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_sessions" + }, + { + "nameExplicit": false, + "columns": [ + "roleId" + ], + "schemaTo": "public", + "tableTo": "core_roles", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "core_users_roleId_core_roles_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_users" + }, + { + "nameExplicit": false, + "columns": [ + "language" + ], + "schemaTo": "public", + "tableTo": "core_languages", + "columnsTo": [ + "code" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET DEFAULT", + "name": "core_users_language_core_languages_code_fk", + "entityType": "fks", + "schema": "public", + "table": "core_users" + }, + { + "nameExplicit": false, + "columns": [ + "avatarId" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_users_avatarId_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_users" + }, + { + "nameExplicit": false, + "columns": [ + "coverId" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_users_coverId_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_users" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_users_confirm_emails_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_users_confirm_emails" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_users_forgot_password_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_users_forgot_password" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_users_secondary_roles_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_users_secondary_roles" + }, + { + "nameExplicit": false, + "columns": [ + "roleId" + ], + "schemaTo": "public", + "tableTo": "core_roles", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_users_secondary_roles_roleId_core_roles_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_users_secondary_roles" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_users_sso_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_users_sso" + }, + { + "nameExplicit": false, + "columns": [ + "avatarFileId" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_users_sso_avatarFileId_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_users_sso" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_users_sso_operations_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_users_sso_profile_sources_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_users_sso_profile_sources" + }, + { + "nameExplicit": true, + "columns": [ + "userId", + "providerId" + ], + "schemaTo": "public", + "tableTo": "core_users_sso", + "columnsTo": [ + "userId", + "providerId" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_users_sso_profile_sources_connection_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_users_sso_profile_sources" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "blog_categories", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "blog_categories_translations_itemId_blog_categories_id_fk", + "entityType": "fks", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "nameExplicit": false, + "columns": [ + "languageId" + ], + "schemaTo": "public", + "tableTo": "core_languages", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "blog_categories_translations_languageId_core_languages_id_fk", + "entityType": "fks", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "nameExplicit": false, + "columns": [ + "hiddenBy" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "blog_posts_hiddenBy_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "blog_posts" + }, + { + "nameExplicit": false, + "columns": [ + "coverImage" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "blog_posts_coverImage_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "blog_posts" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "blog_posts", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "blog_posts_author_id_itemId_blog_posts_id_fk", + "entityType": "fks", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "nameExplicit": false, + "columns": [ + "relatedItemId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "blog_posts_author_id_relatedItemId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "blog_posts", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "blog_posts_category_id_itemId_blog_posts_id_fk", + "entityType": "fks", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "nameExplicit": false, + "columns": [ + "relatedItemId" + ], + "schemaTo": "public", + "tableTo": "blog_categories", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "blog_posts_category_id_relatedItemId_blog_categories_id_fk", + "entityType": "fks", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "blog_posts", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "blog_posts_translations_itemId_blog_posts_id_fk", + "entityType": "fks", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "nameExplicit": false, + "columns": [ + "languageId" + ], + "schemaTo": "public", + "tableTo": "core_languages", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "blog_posts_translations_languageId_core_languages_id_fk", + "entityType": "fks", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "example_advanced_articles", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_advanced_articles_categories_itemId_example_advanced_ar", + "entityType": "fks", + "schema": "public", + "table": "example_advanced_articles_categories" + }, + { + "nameExplicit": false, + "columns": [ + "relatedItemId" + ], + "schemaTo": "public", + "tableTo": "example_categories", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "example_advanced_articles_categories_relatedItemId_example_cate", + "entityType": "fks", + "schema": "public", + "table": "example_advanced_articles_categories" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "example_advanced_articles", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_advanced_articles_faq_itemId_example_advanced_articles_", + "entityType": "fks", + "schema": "public", + "table": "example_advanced_articles_faq" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "example_advanced_articles", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_advanced_articles_related_articles_itemId_example_advan", + "entityType": "fks", + "schema": "public", + "table": "example_advanced_articles_related_articles" + }, + { + "nameExplicit": false, + "columns": [ + "relatedItemId" + ], + "schemaTo": "public", + "tableTo": "example_advanced_articles", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_advanced_articles_related_articles_relatedItemId_exampl", + "entityType": "fks", + "schema": "public", + "table": "example_advanced_articles_related_articles" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "example_advanced_articles", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_advanced_articles_tags_ATchHLRtvoCM_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_advanced_articles_tags" + }, + { + "nameExplicit": false, + "columns": [ + "relatedItemId" + ], + "schemaTo": "public", + "tableTo": "example_tags", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "example_advanced_articles_tags_dEsWdkDmu7nW_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_advanced_articles_tags" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "example_advanced_articles", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_advanced_articles_translations_itemId_example_advanced_", + "entityType": "fks", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "nameExplicit": false, + "columns": [ + "languageId" + ], + "schemaTo": "public", + "tableTo": "core_languages", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "example_advanced_articles_translations_languageId_core_language", + "entityType": "fks", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "nameExplicit": false, + "columns": [ + "author" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "example_articles_author_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": false, + "columns": [ + "animation" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "example_articles_animation_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": false, + "columns": [ + "category" + ], + "schemaTo": "public", + "tableTo": "example_categories", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "example_articles_category_example_categories_id_fk", + "entityType": "fks", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "example_articles", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_articles_gallery_itemId_example_articles_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_articles_gallery" + }, + { + "nameExplicit": false, + "columns": [ + "relatedItemId" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "example_articles_gallery_relatedItemId_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_articles_gallery" + }, + { + "nameExplicit": false, + "columns": [ + "hiddenBy" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "example_events_hiddenBy_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_events" + }, + { + "nameExplicit": false, + "columns": [ + "primaryTag" + ], + "schemaTo": "public", + "tableTo": "example_tags", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "example_events_primaryTag_example_tags_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_events" + }, + { + "nameExplicit": false, + "columns": [ + "category" + ], + "schemaTo": "public", + "tableTo": "example_categories", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "example_events_category_example_categories_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_events" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "example_events", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_events_gallery_itemId_example_events_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_events_gallery" + }, + { + "nameExplicit": false, + "columns": [ + "relatedItemId" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "example_events_gallery_relatedItemId_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_events_gallery" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "example_events", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_events_related_events_itemId_example_events_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_events_related_events" + }, + { + "nameExplicit": false, + "columns": [ + "relatedItemId" + ], + "schemaTo": "public", + "tableTo": "example_events", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_events_related_events_oaRvqnKyAS7L_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_events_related_events" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "example_events", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_events_sessions_itemId_example_events_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_events_sessions" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "example_events", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_events_tags_itemId_example_events_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_events_tags" + }, + { + "nameExplicit": false, + "columns": [ + "relatedItemId" + ], + "schemaTo": "public", + "tableTo": "example_tags", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "example_events_tags_relatedItemId_example_tags_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_events_tags" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "example_events", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_events_translations_itemId_example_events_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_events_translations" + }, + { + "nameExplicit": false, + "columns": [ + "languageId" + ], + "schemaTo": "public", + "tableTo": "core_languages", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "example_events_translations_languageId_core_languages_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_events_translations" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "example_localized_articles", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_localized_articles_translations_itemId_example_localize", + "entityType": "fks", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "nameExplicit": false, + "columns": [ + "languageId" + ], + "schemaTo": "public", + "tableTo": "core_languages", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "example_localized_articles_translations_languageId_core_languag", + "entityType": "fks", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "nameExplicit": false, + "columns": [ + "hiddenBy" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "example_tags_hiddenBy_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_tags" + }, + { + "nameExplicit": false, + "columns": [ + "category" + ], + "schemaTo": "public", + "tableTo": "example_categories", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "example_tags_category_example_categories_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_tags" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "example_tags", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_tags_related_tags_itemId_example_tags_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_tags_related_tags" + }, + { + "nameExplicit": false, + "columns": [ + "relatedItemId" + ], + "schemaTo": "public", + "tableTo": "example_tags", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_tags_related_tags_relatedItemId_example_tags_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_tags_related_tags" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "example_tags", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_tags_translations_itemId_example_tags_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_tags_translations" + }, + { + "nameExplicit": false, + "columns": [ + "languageId" + ], + "schemaTo": "public", + "tableTo": "core_languages", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "example_tags_translations_languageId_core_languages_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_tags_translations" + }, + { + "columns": [ + "roleId", + "permission" + ], + "nameExplicit": false, + "name": "core_ai_role_permissions_pkey", + "entityType": "pks", + "schema": "public", + "table": "core_ai_role_permissions" + }, + { + "columns": [ + "eventId", + "userId" + ], + "nameExplicit": false, + "name": "core_notification_receipts_pkey", + "entityType": "pks", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "columns": [ + "userId", + "roleId" + ], + "nameExplicit": false, + "name": "core_users_secondary_roles_userId_roleId_pk", + "entityType": "pks", + "schema": "public", + "table": "core_users_secondary_roles" + }, + { + "columns": [ + "userId", + "field" + ], + "nameExplicit": false, + "name": "core_users_sso_profile_sources_pkey", + "entityType": "pks", + "schema": "public", + "table": "core_users_sso_profile_sources" + }, + { + "columns": [ + "itemId", + "languageId" + ], + "nameExplicit": true, + "name": "blog_categories_translations_item_id_language_id_pk", + "entityType": "pks", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "columns": [ + "itemId", + "relatedItemId" + ], + "nameExplicit": true, + "name": "blog_posts_author_id_pk", + "entityType": "pks", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "columns": [ + "itemId", + "relatedItemId" + ], + "nameExplicit": true, + "name": "blog_posts_category_id_pk", + "entityType": "pks", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "columns": [ + "itemId", + "languageId" + ], + "nameExplicit": true, + "name": "blog_posts_translations_item_id_language_id_pk", + "entityType": "pks", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "columns": [ + "itemId", + "relatedItemId" + ], + "nameExplicit": true, + "name": "example_advanced_articles_categories_pk", + "entityType": "pks", + "schema": "public", + "table": "example_advanced_articles_categories" + }, + { + "columns": [ + "itemId", + "relatedItemId" + ], + "nameExplicit": true, + "name": "example_advanced_articles_related_articles_pk", + "entityType": "pks", + "schema": "public", + "table": "example_advanced_articles_related_articles" + }, + { + "columns": [ + "itemId", + "relatedItemId" + ], + "nameExplicit": true, + "name": "example_advanced_articles_tags_pk", + "entityType": "pks", + "schema": "public", + "table": "example_advanced_articles_tags" + }, + { + "columns": [ + "itemId", + "languageId" + ], + "nameExplicit": true, + "name": "example_advanced_articles_translations_item_id_language_id_pk", + "entityType": "pks", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "columns": [ + "itemId", + "relatedItemId" + ], + "nameExplicit": true, + "name": "example_articles_gallery_pk", + "entityType": "pks", + "schema": "public", + "table": "example_articles_gallery" + }, + { + "columns": [ + "itemId", + "relatedItemId" + ], + "nameExplicit": true, + "name": "example_events_gallery_pk", + "entityType": "pks", + "schema": "public", + "table": "example_events_gallery" + }, + { + "columns": [ + "itemId", + "relatedItemId" + ], + "nameExplicit": true, + "name": "example_events_related_events_pk", + "entityType": "pks", + "schema": "public", + "table": "example_events_related_events" + }, + { + "columns": [ + "itemId", + "relatedItemId" + ], + "nameExplicit": true, + "name": "example_events_tags_pk", + "entityType": "pks", + "schema": "public", + "table": "example_events_tags" + }, + { + "columns": [ + "itemId", + "languageId" + ], + "nameExplicit": true, + "name": "example_events_translations_item_id_language_id_pk", + "entityType": "pks", + "schema": "public", + "table": "example_events_translations" + }, + { + "columns": [ + "itemId", + "languageId" + ], + "nameExplicit": true, + "name": "example_localized_articles_translations_item_id_language_id_pk", + "entityType": "pks", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "columns": [ + "itemId", + "relatedItemId" + ], + "nameExplicit": true, + "name": "example_tags_related_tags_pk", + "entityType": "pks", + "schema": "public", + "table": "example_tags_related_tags" + }, + { + "columns": [ + "itemId", + "languageId" + ], + "nameExplicit": true, + "name": "example_tags_translations_item_id_language_id_pk", + "entityType": "pks", + "schema": "public", + "table": "example_tags_translations" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_admin_permissions_pkey", + "schema": "public", + "table": "core_admin_permissions", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_admin_sessions_pkey", + "schema": "public", + "table": "core_admin_sessions", + "entityType": "pks" + }, + { + "columns": [ + "actionKey" + ], + "nameExplicit": false, + "name": "core_ai_action_settings_pkey", + "schema": "public", + "table": "core_ai_action_settings", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_ai_budget_periods_pkey", + "schema": "public", + "table": "core_ai_budget_periods", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_ai_calls_pkey", + "schema": "public", + "table": "core_ai_calls", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_ai_cost_adjustments_pkey", + "schema": "public", + "table": "core_ai_cost_adjustments", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_ai_reservations_pkey", + "schema": "public", + "table": "core_ai_reservations", + "entityType": "pks" + }, + { + "columns": [ + "roleId" + ], + "nameExplicit": false, + "name": "core_ai_role_policies_pkey", + "schema": "public", + "table": "core_ai_role_policies", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_ai_runs_pkey", + "schema": "public", + "table": "core_ai_runs", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_ai_settings_pkey", + "schema": "public", + "table": "core_ai_settings", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_ai_translation_sources_pkey", + "schema": "public", + "table": "core_ai_translation_sources", + "entityType": "pks" + }, + { + "columns": [ + "userId" + ], + "nameExplicit": false, + "name": "core_ai_user_overrides_pkey", + "schema": "public", + "table": "core_ai_user_overrides", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_content_documents_pkey", + "schema": "public", + "table": "core_content_documents", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_content_drafts_pkey", + "schema": "public", + "table": "core_content_drafts", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_content_field_locks_pkey", + "schema": "public", + "table": "core_content_field_locks", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_content_file_refs_pkey", + "schema": "public", + "table": "core_content_file_refs", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_content_revisions_pkey", + "schema": "public", + "table": "core_content_revisions", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_content_schedules_pkey", + "schema": "public", + "table": "core_content_schedules", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_content_slug_history_pkey", + "schema": "public", + "table": "core_content_slug_history", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_cron_pkey", + "schema": "public", + "table": "core_cron", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_admin_dashboard_pkey", + "schema": "public", + "table": "core_admin_dashboard", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_files_pkey", + "schema": "public", + "table": "core_files", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_files_alt_pkey", + "schema": "public", + "table": "core_files_alt", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_files_alt_analysis_pkey", + "schema": "public", + "table": "core_files_alt_analysis", + "entityType": "pks" + }, + { + "columns": [ + "fileId" + ], + "nameExplicit": false, + "name": "core_files_alt_state_pkey", + "schema": "public", + "table": "core_files_alt_state", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_languages_pkey", + "schema": "public", + "table": "core_languages", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_languages_words_pkey", + "schema": "public", + "table": "core_languages_words", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_logs_pkey", + "schema": "public", + "table": "core_logs", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_moderators_permissions_pkey", + "schema": "public", + "table": "core_moderators_permissions", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_navigation_pkey", + "schema": "public", + "table": "core_navigation", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_notification_deliveries_pkey", + "schema": "public", + "table": "core_notification_deliveries", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_notification_events_pkey", + "schema": "public", + "table": "core_notification_events", + "entityType": "pks" + }, + { + "columns": [ + "key" + ], + "nameExplicit": false, + "name": "core_notification_settings_pkey", + "schema": "public", + "table": "core_notification_settings", + "entityType": "pks" + }, + { + "columns": [ + "userId" + ], + "nameExplicit": false, + "name": "core_notification_user_state_pkey", + "schema": "public", + "table": "core_notification_user_state", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_notifications_pkey", + "schema": "public", + "table": "core_notifications", + "entityType": "pks" + }, + { + "columns": [ + "pageId" + ], + "nameExplicit": false, + "name": "core_page_layouts_pkey", + "schema": "public", + "table": "core_page_layouts", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_users_passkey_challenges_pkey", + "schema": "public", + "table": "core_users_passkey_challenges", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_users_passkeys_pkey", + "schema": "public", + "table": "core_users_passkeys", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_queue_pkey", + "schema": "public", + "table": "core_queue", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_roles_pkey", + "schema": "public", + "table": "core_roles", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_search_index_pkey", + "schema": "public", + "table": "core_search_index", + "entityType": "pks" + }, + { + "columns": [ + "name" + ], + "nameExplicit": false, + "name": "core_secrets_pkey", + "schema": "public", + "table": "core_secrets", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_sessions_pkey", + "schema": "public", + "table": "core_sessions", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_sessions_known_devices_pkey", + "schema": "public", + "table": "core_sessions_known_devices", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_users_pkey", + "schema": "public", + "table": "core_users", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_users_confirm_emails_pkey", + "schema": "public", + "table": "core_users_confirm_emails", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_users_forgot_password_pkey", + "schema": "public", + "table": "core_users_forgot_password", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_users_sso_operations_pkey", + "schema": "public", + "table": "core_users_sso_operations", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "blog_categories_pkey", + "schema": "public", + "table": "blog_categories", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "blog_posts_pkey", + "schema": "public", + "table": "blog_posts", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "example_advanced_articles_pkey", + "schema": "public", + "table": "example_advanced_articles", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "example_advanced_articles_faq_pkey", + "schema": "public", + "table": "example_advanced_articles_faq", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "example_articles_pkey", + "schema": "public", + "table": "example_articles", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "example_categories_pkey", + "schema": "public", + "table": "example_categories", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "example_events_pkey", + "schema": "public", + "table": "example_events", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "example_events_sessions_pkey", + "schema": "public", + "table": "example_events_sessions", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "example_localized_articles_pkey", + "schema": "public", + "table": "example_localized_articles", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "example_pages_pkey", + "schema": "public", + "table": "example_pages", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "example_tags_pkey", + "schema": "public", + "table": "example_tags", + "entityType": "pks" + }, + { + "nameExplicit": true, + "columns": [ + "itemType", + "itemId", + "languageCode" + ], + "nullsNotDistinct": false, + "name": "core_search_index_item_unique", + "entityType": "uniques", + "schema": "public", + "table": "core_search_index" + }, + { + "nameExplicit": true, + "columns": [ + "providerId", + "providerAccountId" + ], + "nullsNotDistinct": false, + "name": "core_users_sso_provider_account_key", + "entityType": "uniques", + "schema": "public", + "table": "core_users_sso" + }, + { + "nameExplicit": true, + "columns": [ + "userId", + "providerId" + ], + "nullsNotDistinct": false, + "name": "core_users_sso_user_provider_key", + "entityType": "uniques", + "schema": "public", + "table": "core_users_sso" + }, + { + "nameExplicit": false, + "columns": [ + "token" + ], + "nullsNotDistinct": false, + "name": "core_admin_sessions_token_unique", + "schema": "public", + "table": "core_admin_sessions", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "nullsNotDistinct": false, + "name": "core_admin_dashboard_userId_unique", + "schema": "public", + "table": "core_admin_dashboard", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "key" + ], + "nullsNotDistinct": false, + "name": "core_files_key_unique", + "schema": "public", + "table": "core_files", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "code" + ], + "nullsNotDistinct": false, + "name": "core_languages_code_unique", + "schema": "public", + "table": "core_languages", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "idempotencyKey" + ], + "nullsNotDistinct": false, + "name": "core_notification_deliveries_idempotencyKey_key", + "schema": "public", + "table": "core_notification_deliveries", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "tokenHash" + ], + "nullsNotDistinct": false, + "name": "core_users_passkey_challenges_tokenHash_key", + "schema": "public", + "table": "core_users_passkey_challenges", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "credentialId" + ], + "nullsNotDistinct": false, + "name": "core_users_passkeys_credentialId_key", + "schema": "public", + "table": "core_users_passkeys", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "token" + ], + "nullsNotDistinct": false, + "name": "core_sessions_token_unique", + "schema": "public", + "table": "core_sessions", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "publicId" + ], + "nullsNotDistinct": false, + "name": "core_sessions_known_devices_publicId_unique", + "schema": "public", + "table": "core_sessions_known_devices", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "nameCode" + ], + "nullsNotDistinct": false, + "name": "core_users_nameCode_unique", + "schema": "public", + "table": "core_users", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "name" + ], + "nullsNotDistinct": false, + "name": "core_users_name_unique", + "schema": "public", + "table": "core_users", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "email" + ], + "nullsNotDistinct": false, + "name": "core_users_email_unique", + "schema": "public", + "table": "core_users", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "token" + ], + "nullsNotDistinct": false, + "name": "core_users_confirm_emails_token_unique", + "schema": "public", + "table": "core_users_confirm_emails", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "nullsNotDistinct": false, + "name": "core_users_forgot_password_userId_unique", + "schema": "public", + "table": "core_users_forgot_password", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "token" + ], + "nullsNotDistinct": false, + "name": "core_users_forgot_password_token_unique", + "schema": "public", + "table": "core_users_forgot_password", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "tokenHash" + ], + "nullsNotDistinct": false, + "name": "core_users_sso_operations_tokenHash_key", + "schema": "public", + "table": "core_users_sso_operations", + "entityType": "uniques" + }, + { + "value": "\"intent\" IN ('link', 'import', 'sync', 'preview')", + "name": "core_users_sso_operations_intent_check", + "entityType": "checks", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "value": "\"field\" IN ('avatar', 'firstName', 'lastName')", + "name": "core_users_sso_profile_sources_field_check", + "entityType": "checks", + "schema": "public", + "table": "core_users_sso_profile_sources" + } + ], + "renames": [] +} \ No newline at end of file diff --git a/apps/web/content/docs/content-engine/admincp.mdx b/apps/web/content/docs/content-engine/admincp.mdx index 2c8da2d6e6..9801b3eace 100644 --- a/apps/web/content/docs/content-engine/admincp.mdx +++ b/apps/web/content/docs/content-engine/admincp.mdx @@ -90,7 +90,7 @@ Each action asks for confirmation and then sends one request per record to the s The **More actions** menu of each row collects the less frequent actions. Two of them come from options in the definition: - **Duplicate** appears with [`duplication`](/docs/content-engine/duplicate-content), for staff with `can_create` and `can_view`. It makes a draft copy and opens it. -- **Hide** or **Unhide** appears with [`visibility`](/docs/content-engine/content-visibility), for staff with `can_hide`. A hidden record gets a **Hidden** badge next to its status, and the toolbar gets a **Visibility** filter with **All**, **Visible** and **Hidden**. +- **Hide** or **Unhide** appears with [`visibility`](/docs/content-engine/content-visibility), for staff with `can_hide`. A hidden record gets an eye-off icon before its title, with a tooltip such as **Article hidden**, and the toolbar gets a **Visibility** filter with **All**, **Visible** and **Hidden**. -A toast says the article is now hidden. The row keeps its **Published** badge and gets a **Hidden** badge next to it, whose tooltip shows when it was hidden. The **Visibility** filter in the list toolbar shows only hidden or only visible records, and it combines with the **Status** filter: +A toast says the article is now hidden. The row keeps its **Published** badge, and its title gets an eye-off icon in front with an **Article hidden** tooltip. The **Visibility** filter in the list toolbar shows only hidden or only visible records, and it combines with the **Status** filter: -The edit page shows the same badge, an **Unhide** button and a note that publishing a hidden record does not make it public: +The edit page shows the same icon before the title, an **Unhide** action (in the blog's editor, it lives in the ▾ menu next to **Save changes**) and a note that publishing a hidden record does not make it public: + Hiding changes nothing else. `status` and `publishedAt` stay as they are, so unhiding a published record puts it back with its original date. Publishing, unpublishing or a scheduled publish never change visibility: publishing a hidden record keeps it off the site until someone unhides it. Restoring a revision never changes visibility either. With [`editorial`](/docs/content-engine/editorial), hiding and unhiding raise the record's `version` and store a revision, shown as **Hidden** or **Unhidden** in the history. Hiding a record that is already hidden changes nothing: no write, no revision and no event. @@ -157,4 +176,4 @@ curl -o /dev/null -w "%{http_code}\n" "http://localhost:3000/blog/second-post" curl -o /dev/null -w "%{http_code}\n" "http://localhost:3000/api/@vitnode/blog/content/blog/second-post" ``` -Both answer `404`, like a draft, and the article drops out of the blog list and the sitemap. In the AdminCP it is still **Published**. Unhide it, and the same requests answer `200` with the original `publishedAt`. +Both answer `404`, like a draft, and the article drops out of the blog list and the sitemap. In the AdminCP it is still **Published**. Unhide it, and the same requests answer `200` with the original `publishedAt`. Open the page while signed in as one of its authors, and it loads with the hidden note instead. diff --git a/apps/web/content/docs/content-engine/live-editing-api.mdx b/apps/web/content/docs/content-engine/live-editing-api.mdx index 32113b1d53..0b1998a046 100644 --- a/apps/web/content/docs/content-engine/live-editing-api.mdx +++ b/apps/web/content/docs/content-engine/live-editing-api.mdx @@ -151,7 +151,7 @@ Register it with `forms: { layout: ArticleFormLayout }` in the type's `contentTy | `ContentLivePresence` | Avatars of everyone else in the record. `max` sets how many show before **+N**, 5 by default | | `ContentLiveFieldPresence` | Avatars of the people in one `field` and `locale` | | `ContentLiveLanguagePresence` | Who is working in one `locale` | -| `ContentLiveStatus` | The autosave state: **Saving…**, **Draft saved at…**, **Draft not saved**, **Unsaved changes** | +| `ContentLiveStatus` | The autosave state: **Saving…**, **Autosaved 5 minutes ago** (with an amber dot while the draft is not saved into the record), **Autosave failed** | | `useContentLive()` | The session: `session.members`, `session.locks`, `status` (`saving`, `savedAt`, `failed`, `dirty`) and `coEditing` | | `useContentRichTextReplace()` | `(field, locale, document) => void` that replaces a rich text field through the shared editor, so everyone sees it and it can be undone. `null` while rich text is not co-edited | diff --git a/apps/web/content/docs/content-engine/live-editing.mdx b/apps/web/content/docs/content-engine/live-editing.mdx index e3074ec0be..ade7b3af13 100644 --- a/apps/web/content/docs/content-engine/live-editing.mdx +++ b/apps/web/content/docs/content-engine/live-editing.mdx @@ -58,9 +58,9 @@ A lock lasts 60 seconds and renews every 20 seconds while the editor stays in th ### Autosave and the shared draft -About 1.5 seconds after you stop typing in a field you hold, the value is saved to the record's draft. The header shows **Saving…** and then **Draft saved at 14:32**, or **Draft not saved** when the write failed. Next to it, **Unsaved changes** stays until someone saves the draft into the record. +About 1.5 seconds after you stop typing in a field you hold, the value is saved to the record's draft. You never lose work, but readers don't see it yet. The header shows **Saving…** and then **Autosaved just now**, later **Autosaved 5 minutes ago**, or **Autosave failed** when the write failed. An amber dot in front means the draft holds changes the record doesn't have yet, and it stays until someone clicks **Save changes**. Hover it for a reminder. -Changed your mind? **Discard** next to **Unsaved changes** throws the draft away after you confirm, and the form goes back to the last saved version. It is a team draft, so everyone editing the record goes back too, and they get a short note saying so. +Changed your mind? **Discard** next to the autosave time throws the draft away after you confirm, and the form goes back to the last saved version. It is a team draft, so everyone editing the record goes back too, and they get a short note saying so. There is one draft per record and language, shared by the whole team. When you open the record, you see the draft, not only what was last saved. **Save** commits it through the usual [version check](/docs/content-engine/editorial#safe-concurrent-edits) and creates one revision. Autosave itself never creates revisions, emits no events and leaves search and caches alone. [Preview links](/docs/content-engine/editorial#preview-links) also ignore the draft until someone saves. @@ -86,6 +86,6 @@ Restoring a revision or deleting the record throws away its drafts, locks and sh 1. Sign in at `/admin` as two staff members with `can_edit`, for example in two browser profiles. 2. Open the same record's edit form in both. 3. Click into **Title** in one window. The other window shows the outline and name tag, and the field turns read-only there. -4. Type a few words and wait two seconds. The other window shows the new value, and the header of the first says **Draft saved at…**. +4. Type a few words and wait two seconds. The other window shows the new value, and the header of the first says **Autosaved just now**. Building your own form layout, or calling the lock and draft routes yourself? See the [live editing API](/docs/content-engine/live-editing-api). diff --git a/apps/web/content/docs/content-engine/reference.mdx b/apps/web/content/docs/content-engine/reference.mdx index e3f314989a..2d1490eae4 100644 --- a/apps/web/content/docs/content-engine/reference.mdx +++ b/apps/web/content/docs/content-engine/reference.mdx @@ -254,6 +254,7 @@ Keeps published records in the [site search index](/docs/dev/search). Drafts nev | `pathTemplate` | `string` | Yes | The result URL. Starts with `/`, contains `{slug}` exactly once, at most 512 characters | | `descriptionField` | field name | No | Shown in result excerpts. Text, textarea or richText | | `authorField` | field name | No | A top-level `field.user()` credited in the index. It does not have to be public | +| `imageField` | field name | No | A shared single `field.file()` shown as the result image. Must be in `publicApi.fields` | Every field except `authorField` must be in `publicApi.fields`, or a result snippet would leak a private value. Leave the language out of `pathTemplate`, because your [i18n settings](/docs/dev/i18n) add the locale prefix. diff --git a/apps/web/content/docs/content-engine/search.mdx b/apps/web/content/docs/content-engine/search.mdx index 93abc81e53..44cedf1b0d 100644 --- a/apps/web/content/docs/content-engine/search.mdx +++ b/apps/web/content/docs/content-engine/search.mdx @@ -55,6 +55,7 @@ export const articleContentType = defineContentType({ - `contentFields` is the searchable body. It takes at least one field and no duplicates. Text, textarea, rich text or slug fields, plus repeatable leaves such as `"faq.answer"`. Rich text is indexed as its words, never as JSON. The title always ranks higher than the body, so listing it here as well only repeats it in the excerpt. - `pathTemplate` is the result URL. It starts with `/` and contains `{slug}` exactly once. Leave the language out, because your i18n settings add it (see [Localized content types](#localized-content-types)). - `authorField` is optional and credits a top-level `field.user()` on [member timelines](/docs/dev/search#member-timelines). +- `imageField` is optional and shows a `field.file()` image next to each result. See [Credit several authors and add an image](#credit-several-authors-and-add-an-image). Every field except `authorField` must be in `publicApi.fields`. Otherwise a result snippet would leak a private value, so the definition throws instead: `search.titleField names "x", which is not in publicApi.fields.` `authorField` may stay private, which is why `author` is not in the public list above. All options are in the [reference](/docs/content-engine/reference#search). @@ -81,6 +82,52 @@ Both buttons need the **Rebuild and clear the search index** staff permission (` +## Credit several authors and add an image + +A result can show everyone who wrote the record and a picture of it, on `/search`, `/discover` and member timelines. The blog does both: an article lists all its authors and its cover image. + +{/* Image prompt: A VitNode search result card in light theme. Top left, three small overlapping round avatars followed by "admin, devloop_tester and aXen · Aug 15, 4:14 PM". Below, a bold title "Hello Content Engine" and two lines of grey excerpt. On the right, a rounded 16:9 cover photo thumbnail. 1200x400. */} + +Make the author field hold several users, add a single image field, and point `search` at both: + +```ts title="plugins/blog/src/content/post.ts" +export const blogPostContentType = defineContentType({ + fields: { + title: field.text({ required: true }), + friendlyUrl: field.slug({ source: "title" }), + content: field.richText(), + // [!code ++:6] + authorId: field.user({ min: 1, multiple: true, ordered: true }), + coverImage: field.file({ + allowedExtensions: [".jpg", ".jpeg", ".png", ".webp", ".avif"], + allowedMimeTypes: ["image/jpeg", "image/png", "image/webp", "image/avif"], + }), + }, + publication: true, + publicApi: { + path: "blog", + fields: ["id", "title", "friendlyUrl", "content", "coverImage"], // [!code highlight] + }, + search: { + titleField: "title", + contentFields: ["title", "content"], + pathTemplate: "/blog/{slug}", + // [!code ++:2] + authorField: "authorId", + imageField: "coverImage", + }, +}); +``` + +- **Authors.** With `multiple: true`, every user in the field is credited, and `ordered: true` keeps the order you pick in the editor. A result shows up to three overlapping avatars and the names in that order, such as **admin, devloop_tester and aXen**. Past three it says **admin, devloop_tester and 2 others**. Every co-author gets the record on their own timeline. +- **Image.** `imageField` names one shared `field.file()`, not a list and not a localized field, and it must be in `publicApi.fields`, because everybody who finds the result sees it. It shows as a small 16:9 thumbnail on the right of the result, and full width above the text on phones. Records without an image simply show none, and a file that is not an image is skipped. + +The definition throws when either one points at the wrong kind of field, for example `search.imageField names "gallery", which is not a shared single field.file() on the record itself.` + +Adding `authorField` or `imageField` to content that already exists needs a **Reindex** of the collection, so older records pick them up. After that, changing the authors or the image re-indexes the record on save. + +In the API, each result carries `authors` (id, name, `nameCode`, avatar) in order, `authorIds`, and `image` with `url`, `width` and `height`, or `null`. + ## What gets indexed and when Every record is in the index, but only public ones show up on `/search`. A draft is stored as a private document without a URL. A [hidden](/docs/content-engine/content-visibility) record's document turns private the moment it is hidden, and public again when it is unhidden. With `authorField`, it appears on its author's own timeline and on the AdminCP user page, but never in public results. diff --git a/apps/web/content/docs/dev/search.mdx b/apps/web/content/docs/dev/search.mdx index 237f045c99..49447808cd 100644 --- a/apps/web/content/docs/dev/search.mdx +++ b/apps/web/content/docs/dev/search.mdx @@ -119,12 +119,14 @@ Every member gets a timeline: everything credited to them, newest first. It live Who sees what depends on who's looking: -| Viewer | Published | Drafts | -| --- | --- | --- | -| Anyone, on `/search` and `/discover` | Yes | No | -| Anyone, on a member's profile | Yes | No | -| The member, on their own profile | Yes | Yes, marked **Draft** | -| Staff, on the AdminCP user page | Yes | Yes, marked **Draft** | +| Viewer | Published | Hidden | Drafts | +| --- | --- | --- | --- | +| Anyone, on `/search` and `/discover` | Yes | Only its authors and editors, with an eye-off icon | No | +| Anyone, on a member's profile | Yes | Only its authors and editors, with an eye-off icon | No | +| The member, on their own profile | Yes | Yes, with an eye-off icon | Yes, marked **Draft** | +| Staff, on the AdminCP user page | Yes | Yes, with an eye-off icon | Yes, marked **Draft** | + +Hidden comes from [Hide content](/docs/content-engine/content-visibility#who-can-still-see-it): an editor is staff with `can_edit` on the content type. A draft has no public page yet, so its title isn't a link. Nobody gets sent to a 404. @@ -145,7 +147,7 @@ export const blogPostContentType = defineContentType({ }) ``` -The first author is the one shown next to the result. After turning `authorField` on for content that already exists, rebuild the index so older records get their authors. +Every author is shown on the result, in the field's order, on `/search`, `/discover` and timelines alike: overlapping avatars, then the names, such as **admin, devloop_tester and aXen**. Past three authors the list shortens to **admin, devloop_tester and 2 others**. Each hit in the API carries `authorIds` and `authors` (id, name, `nameCode`, avatar) in that order, and `author` stays the first one for code that only needs a single name. After turning `authorField` on for content that already exists, rebuild the index so older records get their authors. The timelines come from two routes: @@ -239,6 +241,17 @@ A rebuild is a queued job, so it only runs once [cron](/docs/dev/cron) is runnin type: 'boolean', default: 'true', }, + imageId: { + description: + 'A `core_files` id shown as the result image. Results resolve it to `image: { url, width, height }`; a file that is not an image is skipped.', + type: 'number', + }, + isHidden: { + description: + 'A published record that was hidden. Set it together with `isPublic: false` and keep `url`: its authors and editors still find it in search and on timelines, marked with an eye-off icon.', + type: 'boolean', + default: 'false', + }, }} /> diff --git a/apps/web/content/docs/guides/blog.mdx b/apps/web/content/docs/guides/blog.mdx index 53b5cd2f6d..09dd0f85a7 100644 --- a/apps/web/content/docs/guides/blog.mdx +++ b/apps/web/content/docs/guides/blog.mdx @@ -108,7 +108,7 @@ The article editor reads top to bottom like the published article: the cover image and its alt text, then the title, slug and content. The checks before publishing live in the **SEO** sheet, so the text gets the whole width. -{/* Image prompt: VitNode AdminCP blog article editor at full width. A wide cover image with Replace file and Remove buttons in its corner, alt text field under it, a large article title and slug, then a TipTap editor. Header with Translate, SEO with a small amber badge "2", AI review, a Draft/Published switch and Save changes. Light theme, 1440x900. */} +{/* Image prompt: VitNode AdminCP blog article editor at full width. A wide cover image with Replace file and Remove buttons in its corner, alt text field under it, a large article title and slug, then a TipTap editor. Header with Translate, SEO with a small amber badge "2", AI review, a muted "● Published" label and a split Save changes button with a dropdown arrow. Light theme, 1440x900. */} ### Check the article before you publish @@ -137,8 +137,11 @@ text, or **Open** a language that is missing a translation. ### Publish or unpublish -The **Draft / Published** switch sits next to **Save changes**. Switching asks -for confirmation first, because it changes what readers see immediately. +The header shows whether the article is a **Draft** or **Published** (hover it +to see when it went live). To change that, open the arrow next to **Save +changes** and pick **Publish** or **Unpublish**. It asks for confirmation +first, because readers see the change immediately. **Hide** and **Unhide** live +in the same menu. ### Hide or duplicate an article diff --git a/apps/web/src/locales/@vitnode/blog/pl.json b/apps/web/src/locales/@vitnode/blog/pl.json index 38df53ecd8..fbc4599d12 100644 --- a/apps/web/src/locales/@vitnode/blog/pl.json +++ b/apps/web/src/locales/@vitnode/blog/pl.json @@ -164,7 +164,12 @@ }, "title": "Blog", "post": { - "published": "Opublikowano" + "published": "Opublikowano", + "hidden": { + "label": "Artykuł ukryty", + "title": "Ten artykuł jest ukryty", + "desc": "Widzą go tylko jego autorzy i redaktorzy. Pozostali zobaczą stronę „nie znaleziono”, a artykuł nie pojawia się w wyszukiwarce ani w mapie strony." + } } }, "@vitnode/blog:categories": "Kategorie", diff --git a/apps/web/src/locales/@vitnode/core/pl.json b/apps/web/src/locales/@vitnode/core/pl.json index 5c2329ada1..eba1ea59a1 100644 --- a/apps/web/src/locales/@vitnode/core/pl.json +++ b/apps/web/src/locales/@vitnode/core/pl.json @@ -2736,6 +2736,8 @@ "discoverDesc": "Zobacz najnowszą aktywność w społeczności.", "discoverTitle": "Odkrywaj", "draft": "Szkic", + "hidden": "{type} (ukryte)", + "more_authors": "{count, plural, one {# inna osoba} few {# inne osoby} many {# innych osób} other {# innej osoby}}", "empty": "Nic tu jeszcze nie ma.", "loadMore": "Wczytaj więcej", "loading": "Wczytywanie…", diff --git a/packages/elasticsearch/src/index.ts b/packages/elasticsearch/src/index.ts index 8f620ccbc9..0ec759ad0a 100644 --- a/packages/elasticsearch/src/index.ts +++ b/packages/elasticsearch/src/index.ts @@ -48,6 +48,8 @@ interface EsSource { containerType: null | string; content: string; createdAt: string; + imageId?: null | number; + isHidden?: boolean; isPublic: boolean; /** * The numeric id, only for a numeric item - the original `integer` mapping @@ -102,6 +104,8 @@ const toSource = (doc: SearchDocument): EsSource => ({ containerId: doc.containerId ?? null, url: doc.url ?? null, isPublic: doc.isPublic ?? true, + isHidden: doc.isHidden ?? false, + imageId: doc.imageId ?? null, metadata: doc.metadata ?? {}, createdAt: (doc.createdAt instanceof Date ? doc.createdAt @@ -109,6 +113,38 @@ const toSource = (doc: SearchDocument): EsSource => ({ ).toISOString(), }); +const visibleFilter = ( + params: SearchQueryParams, +): estypes.QueryDslQueryContainer => { + const access = params.hiddenAccess; + const viewer: estypes.QueryDslQueryContainer[] = [ + ...(access?.authorId === undefined + ? [] + : [{ term: { authorIds: access.authorId } }]), + ...(access?.itemTypes.length + ? [{ terms: { itemType: [...access.itemTypes] } }] + : []), + ]; + if (viewer.length === 0) return { term: { isPublic: true } }; + + return { + bool: { + minimum_should_match: 1, + should: [ + { term: { isPublic: true } }, + { + bool: { + filter: [ + { term: { isHidden: true } }, + { bool: { minimum_should_match: 1, should: viewer } }, + ], + }, + }, + ], + }, + }; +}; + const buildFilters = ( params: SearchQueryParams, ): estypes.QueryDslQueryContainer[] => { @@ -138,7 +174,7 @@ const buildFilters = ( }); } if (!params.includePrivate) { - filter.push({ term: { isPublic: true } }); + filter.push(visibleFilter(params)); } return filter; @@ -232,11 +268,16 @@ const mapHit = (hit: estypes.SearchHit): null | SearchHit => { itemId, languageCode: source.languageCode, authorId: source.authorIds?.[0] ?? null, + authorIds: source.authorIds ?? [], + authors: [], title: source.title, content: source.content, containerType: source.containerType, containerId: source.containerId, url: source.url, + isHidden: source.isHidden ?? false, + image: null, + imageId: source.imageId ?? null, isPublic: source.isPublic, metadata: source.metadata, createdAt: new Date(source.createdAt), @@ -282,7 +323,11 @@ export const ElasticsearchSearchAdapter = ( // read and delete below still honours. Rebuild the index to backfill. await es.indices.putMapping({ index, - properties: { itemKey: { type: "keyword" } }, + properties: { + imageId: { type: "integer" }, + isHidden: { type: "boolean" }, + itemKey: { type: "keyword" }, + }, }); return; @@ -305,6 +350,8 @@ export const ElasticsearchSearchAdapter = ( containerId: { type: "integer" }, url: { type: "keyword" }, isPublic: { type: "boolean" }, + isHidden: { type: "boolean" }, + imageId: { type: "integer" }, createdAt: { type: "date" }, metadata: { type: "object", enabled: false }, }, diff --git a/packages/vitnode/src/api/adapters/search/postgres.ts b/packages/vitnode/src/api/adapters/search/postgres.ts index fcba37ab9e..8b9f691f09 100644 --- a/packages/vitnode/src/api/adapters/search/postgres.ts +++ b/packages/vitnode/src/api/adapters/search/postgres.ts @@ -1,4 +1,5 @@ import type { SQL } from "drizzle-orm"; +import type { PgColumn } from "drizzle-orm/pg-core"; import type { Context } from "hono"; import { @@ -46,6 +47,27 @@ const requestedTranslation = alias( const DEFAULT_PAGE_SIZE = 20; const MAX_PAGE_SIZE = 100; +const visibleCondition = ( + table: Record<"authorIds" | "isHidden" | "isPublic" | "itemType", PgColumn>, + params: SearchQueryParams, +): SQL | undefined => { + if (params.includePrivate) return undefined; + + const access = params.hiddenAccess; + const viewer = or( + access?.authorId === undefined + ? undefined + : arrayContains(table.authorIds, [access.authorId]), + access?.itemTypes.length + ? inArray(table.itemType, [...access.itemTypes]) + : undefined, + ); + + return viewer + ? or(eq(table.isPublic, true), and(eq(table.isHidden, true), viewer)) + : eq(table.isPublic, true); +}; + const fallbackLanguageConditions = ( c: Context, params: SearchQueryParams, @@ -65,9 +87,7 @@ const fallbackLanguageConditions = ( eq(requestedTranslation.itemType, core_search_index.itemType), eq(requestedTranslation.itemId, core_search_index.itemId), eq(requestedTranslation.languageCode, languageCode), - params.includePrivate - ? undefined - : eq(requestedTranslation.isPublic, true), + visibleCondition(requestedTranslation, params), ), ), ), @@ -119,9 +139,8 @@ const buildFilters = ( if (params.dateTo) { conditions.push(lte(core_search_index.createdAt, params.dateTo)); } - if (!params.includePrivate) { - conditions.push(eq(core_search_index.isPublic, true)); - } + const visible = visibleCondition(core_search_index, params); + if (visible) conditions.push(visible); return conditions.length ? and(...conditions) : undefined; }; @@ -217,12 +236,15 @@ export const PostgresSearchAdapter = (): SearchProviderApiPlugin => ({ itemId: core_search_index.itemId, languageCode: core_search_index.languageCode, authorId: leadAuthorId, + authorIds: core_search_index.authorIds, title: core_search_index.title, content: core_search_index.content, containerType: core_search_index.containerType, containerId: core_search_index.containerId, url: core_search_index.url, isPublic: core_search_index.isPublic, + isHidden: core_search_index.isHidden, + imageId: core_search_index.imageId, metadata: core_search_index.metadata, createdAt: core_search_index.createdAt, score: rankExpr, @@ -254,12 +276,17 @@ export const PostgresSearchAdapter = (): SearchProviderApiPlugin => ({ itemId: searchItemIdFromKey(contentTypes, row.itemType, row.itemId), languageCode: row.languageCode, authorId: row.authorId, + authorIds: row.authorIds, + authors: [], title: row.title, content: row.content, containerType: row.containerType, containerId: row.containerId, url: row.url, isPublic: row.isPublic, + isHidden: row.isHidden, + image: null, + imageId: row.imageId, metadata: row.metadata, createdAt: row.createdAt, score: row.score, diff --git a/packages/vitnode/src/api/lib/response-cache-headers.test.ts b/packages/vitnode/src/api/lib/response-cache-headers.test.ts index 1a771b7a99..9af6d8c732 100644 --- a/packages/vitnode/src/api/lib/response-cache-headers.test.ts +++ b/packages/vitnode/src/api/lib/response-cache-headers.test.ts @@ -111,6 +111,7 @@ const isSharedCacheable = (value: string): boolean => { }; const DECLARED = { + "content/server/delivery-routes.ts": ["private, no-store"], "content/server/public-routes.ts": ["private, no-store"], "tanstack/start/document-headers.ts": ["private, no-store"], } satisfies Record; diff --git a/packages/vitnode/src/api/models/search.ts b/packages/vitnode/src/api/models/search.ts index ceae1505d0..2c7b5743c6 100644 --- a/packages/vitnode/src/api/models/search.ts +++ b/packages/vitnode/src/api/models/search.ts @@ -12,6 +12,7 @@ import { contentLocalesMatch } from "@/content/locale"; import { core_files } from "@/database/files"; import { core_search_index } from "@/database/search"; import { core_users } from "@/database/users"; +import { parseImageDimensions } from "@/lib/api/upload"; import { stripHtml } from "@/lib/strip-html"; const authorAvatarFile = alias(core_files, "search_author_avatar_file"); @@ -22,6 +23,8 @@ export interface SearchDocument { containerType?: string; content: string; createdAt: Date; + imageId?: null | number; + isHidden?: boolean; isPublic?: boolean; /** * The item's own id: a number for every non-content indexer and every @@ -51,6 +54,7 @@ export interface SearchQueryParams { dateFrom?: Date; dateTo?: Date; first?: number; + hiddenAccess?: SearchHiddenAccess; includePrivate?: boolean; itemTypes?: string[]; // Restrict results to one locale (the viewer's). Rows with an empty @@ -61,6 +65,11 @@ export interface SearchQueryParams { term?: string; } +export interface SearchHiddenAccess { + authorId?: number; + itemTypes: readonly string[]; +} + export interface SearchLanguageFallback { itemType: string; languageCode: string; @@ -114,14 +123,25 @@ export interface SearchHitAuthor { nameCode: string; } +export interface SearchHitImage { + height?: number; + url: string; + width?: number; +} + export interface SearchHit { author: null | SearchHitAuthor; authorId: null | number; + authorIds: number[]; + authors: SearchHitAuthor[]; containerId: null | number; containerType: null | string; content: string; createdAt: Date; id: number; + image: null | SearchHitImage; + imageId: null | number; + isHidden: boolean; isPublic: boolean; /** As on {@link SearchDocument}: read back with {@link searchItemIdFromKey}. */ itemId: ContentId; @@ -319,6 +339,8 @@ const toRow = (doc: SearchDocument) => ({ containerId: doc.containerId ?? null, url: doc.url ?? null, isPublic: doc.isPublic ?? true, + isHidden: doc.isHidden ?? false, + imageId: doc.imageId ?? null, metadata: doc.metadata ?? {}, createdAt: doc.createdAt, updatedAt: doc.updatedAt ?? null, @@ -332,17 +354,17 @@ export class SearchModel { protected readonly c: Context; - // Providers that don't join the users table (e.g. Elasticsearch) return hits - // with `authorId` but `author: null`; fill in the display fields here. private async hydrateAuthors(edges: SearchHit[]): Promise { - const missing = [ - ...new Set( - edges.flatMap(edge => - !edge.author && edge.authorId ? [edge.authorId] : [], - ), - ), - ]; - if (missing.length === 0) return edges; + const idsOf = (edge: SearchHit): number[] => + edge.authorIds.length > 0 + ? edge.authorIds + : edge.authorId + ? [edge.authorId] + : []; + const wanted = [...new Set(edges.flatMap(idsOf))]; + if (wanted.length === 0) { + return edges.map(edge => ({ ...edge, authorIds: [], authors: [] })); + } const rows = await this.c .get("db") @@ -355,7 +377,7 @@ export class SearchModel { }) .from(core_users) .leftJoin(authorAvatarFile, eq(authorAvatarFile.id, core_users.avatarId)) - .where(inArray(core_users.id, missing)); + .where(inArray(core_users.id, wanted)); const byId = new Map( rows.map(({ avatarKey, ...row }) => [ @@ -364,11 +386,70 @@ export class SearchModel { ]), ); - return edges.map(edge => - edge.author || !edge.authorId - ? edge - : { ...edge, author: byId.get(edge.authorId) ?? null }, + return edges.map(edge => { + const authorIds = idsOf(edge); + const authors = authorIds.flatMap(id => { + const author = byId.get(id); + + return author ? [author] : []; + }); + + return { + ...edge, + author: edge.author ?? authors[0] ?? null, + authorIds, + authors, + }; + }); + } + + private async hydrateImages(edges: SearchHit[]): Promise { + const wanted = [ + ...new Set( + edges.flatMap(edge => (edge.imageId === null ? [] : [edge.imageId])), + ), + ]; + if (wanted.length === 0) { + return edges.map(edge => ({ ...edge, image: null })); + } + + const rows = await this.c + .get("db") + .select({ + id: core_files.id, + key: core_files.key, + metadata: core_files.metadata, + mimeType: core_files.mimeType, + }) + .from(core_files) + .where(inArray(core_files.id, wanted)); + + const byId = new Map( + rows.flatMap(row => { + const url = row.mimeType?.startsWith("image/") + ? storageUrlOf(this.c, row.key) + : null; + if (!url) return []; + const dimensions = parseImageDimensions(row.metadata); + + return [ + [ + row.id, + { + url, + ...(dimensions + ? { height: dimensions.height, width: dimensions.width } + : {}), + }, + ] as const, + ]; + }), ); + + return edges.map(edge => ({ + ...edge, + image: edge.imageId === null ? null : (byId.get(edge.imageId) ?? null), + })); } private provider(): SearchProviderApiPlugin { @@ -417,6 +498,8 @@ export class SearchModel { containerId: row.containerId, url: row.url, isPublic: row.isPublic, + isHidden: row.isHidden, + imageId: row.imageId, metadata: row.metadata, createdAt: row.createdAt, updatedAt: row.updatedAt, @@ -535,6 +618,9 @@ export class SearchModel { languageFallbacks.length > 0 ? { ...params, languageFallbacks } : params, ); - return { ...result, edges: await this.hydrateAuthors(result.edges) }; + return { + ...result, + edges: await this.hydrateImages(await this.hydrateAuthors(result.edges)), + }; } } diff --git a/packages/vitnode/src/api/modules/search/routes/search.route.ts b/packages/vitnode/src/api/modules/search/routes/search.route.ts index a9efd9e859..7b87cfa836 100644 --- a/packages/vitnode/src/api/modules/search/routes/search.route.ts +++ b/packages/vitnode/src/api/modules/search/routes/search.route.ts @@ -1,6 +1,7 @@ import { z } from "@hono/zod-openapi"; import { CONFIG_PLUGIN } from "@/config"; +import { contentSearchHiddenAccess } from "@/content/server/hidden-access"; import { buildRoute } from "../../../lib/route"; import { zodPaginationQuery } from "../../../lib/with-pagination"; @@ -14,6 +15,14 @@ const zodSearchPageInfo = z.object({ endCursor: z.number().nullable(), }); +const zodSearchHitAuthor = z.object({ + id: z.number(), + name: z.string(), + nameCode: z.string(), + avatarColor: z.string(), + avatarUrl: z.string().nullable(), +}); + export const zodSearchHitSchema = z.object({ id: z.number(), pluginId: z.string(), @@ -28,18 +37,21 @@ export const zodSearchHitSchema = z.object({ containerId: z.number().nullable(), url: z.string().nullable(), isPublic: z.boolean(), - metadata: z.record(z.string(), z.unknown()), - createdAt: z.date(), - score: z.number().nullable(), - author: z + isHidden: z.boolean(), + image: z .object({ - id: z.number(), - name: z.string(), - nameCode: z.string(), - avatarColor: z.string(), - avatarUrl: z.string().nullable(), + url: z.string(), + width: z.number().optional(), + height: z.number().optional(), }) .nullable(), + imageId: z.number().nullable(), + metadata: z.record(z.string(), z.unknown()), + createdAt: z.date(), + score: z.number().nullable(), + author: zodSearchHitAuthor.nullable(), + authorIds: z.array(z.number()), + authors: z.array(zodSearchHitAuthor), }); /** @@ -125,6 +137,7 @@ export const searchRoute = buildRoute({ first: positiveIntOrUndefined(query.first), cursor: query.cursor, languageCode: query.lang, + hiddenAccess: await contentSearchHiddenAccess(c), includePrivate: false, }); diff --git a/packages/vitnode/src/api/modules/search/routes/timeline.route.ts b/packages/vitnode/src/api/modules/search/routes/timeline.route.ts index 3daecd4741..8a0cd5211b 100644 --- a/packages/vitnode/src/api/modules/search/routes/timeline.route.ts +++ b/packages/vitnode/src/api/modules/search/routes/timeline.route.ts @@ -2,7 +2,10 @@ import type { Context } from "hono"; import { z } from "@hono/zod-openapi"; +import type { SearchHiddenAccess } from "@/api/models/search"; + import { CONFIG_PLUGIN } from "@/config"; +import { contentSearchHiddenAccess } from "@/content/server/hidden-access"; import { buildRoute } from "../../../lib/route"; import { zodPaginationQuery } from "../../../lib/with-pagination"; @@ -21,10 +24,12 @@ export const searchTimeline = async ( c: Context, { authorId, + hiddenAccess, includePrivate, query, }: { authorId: number; + hiddenAccess?: SearchHiddenAccess; includePrivate: boolean; query: z.infer; }, @@ -33,6 +38,7 @@ export const searchTimeline = async ( authorId, cursor: query.cursor, first: positiveIntOrUndefined(query.first), + hiddenAccess, includePrivate, languageCode: query.lang, sort: "newest", @@ -63,9 +69,13 @@ export const timelineRoute = buildRoute({ handler: async c => { const authorId = Number(c.req.valid("param").userId); + const includePrivate = c.get("user")?.id === authorId; const result = await searchTimeline(c, { authorId, - includePrivate: c.get("user")?.id === authorId, + hiddenAccess: includePrivate + ? undefined + : await contentSearchHiddenAccess(c), + includePrivate, query: c.req.valid("query"), }); diff --git a/packages/vitnode/src/components/hidden-icon.tsx b/packages/vitnode/src/components/hidden-icon.tsx new file mode 100644 index 0000000000..72e4741b86 --- /dev/null +++ b/packages/vitnode/src/components/hidden-icon.tsx @@ -0,0 +1,28 @@ +import { cn } from "cn"; +import { EyeOffIcon } from "lucide-react"; + +import { TooltipWithContent } from "@/components/ui/tooltip"; + +export const HiddenIcon = ({ + className, + focusable = true, + label, +}: { + className?: string; + focusable?: boolean; + label: string; +}) => ( + + + + + +); diff --git a/packages/vitnode/src/content/admin/spec.test.ts b/packages/vitnode/src/content/admin/spec.test.ts index 485068ba30..fcbc97319f 100644 --- a/packages/vitnode/src/content/admin/spec.test.ts +++ b/packages/vitnode/src/content/admin/spec.test.ts @@ -290,7 +290,13 @@ describe("buildContentColumnSpec", () => { expect( buildContentColumnSpec({ definition: listed, labelEnum, labelField }), ).toEqual([ - { kind: "text", label: "Title", name: "title", thumbnail: "cover" }, + { + kind: "text", + label: "Title", + name: "title", + thumbnail: "cover", + title: true, + }, { kind: "user", label: "Authors", multiple: true, name: "authors" }, ]); }); diff --git a/packages/vitnode/src/content/admin/spec.ts b/packages/vitnode/src/content/admin/spec.ts index ee9cee64bb..9b68849ac0 100644 --- a/packages/vitnode/src/content/admin/spec.ts +++ b/packages/vitnode/src/content/admin/spec.ts @@ -115,6 +115,7 @@ export interface ContentColumnSpec { /** Enum value -> translated label, for badge cells. */ options?: Record; thumbnail?: string; + title?: boolean; } export type ContentFieldLabeller = ( @@ -365,6 +366,7 @@ export const buildContentColumnSpec = ({ ...(fieldValue && isContentReferenceCollection(fieldValue) ? { multiple: true } : {}), + ...(name === definition.admin.titleField ? { title: true } : {}), ...(name === definition.admin.titleField && definition.admin.list.thumbnailField !== null ? { thumbnail: definition.admin.list.thumbnailField } diff --git a/packages/vitnode/src/content/define-search.ts b/packages/vitnode/src/content/define-search.ts index 26c4181615..5f8f009b89 100644 --- a/packages/vitnode/src/content/define-search.ts +++ b/packages/vitnode/src/content/define-search.ts @@ -30,6 +30,7 @@ const disabledSearch: ResolvedContentSearchConfig = { contentFields: [], descriptionField: null, enabled: false, + imageField: null, pathTemplate: "", titleField: "", }; @@ -105,6 +106,41 @@ const assertSearchAuthorField = ( } }; +const assertSearchImageField = ( + id: string, + fields: ContentFieldMap, + exposed: ReadonlySet, + name: string, +): void => { + const target = resolveFieldTarget(fields, name); + if (!target) { + throw new ContentEngineError( + `search.imageField references unknown field "${name}".`, + { contentTypeId: id }, + ); + } + + const { descriptor } = target; + if ( + target.container !== "row" || + descriptor.kind !== "file" || + descriptor.multiple || + descriptor.localized === true + ) { + throw new ContentEngineError( + `search.imageField names "${name}", which is not a shared single \`field.file()\` on the record itself. A result shows one image.`, + { contentTypeId: id }, + ); + } + + if (!exposed.has(name)) { + throw new ContentEngineError( + `search.imageField names "${name}", which is not in publicApi.fields. A result shows the image to everyone who can find it, so it has to be public already.`, + { contentTypeId: id }, + ); + } +}; + const LEGACY_LOCALE_PREFIX = `/${CONTENT_SEARCH_LOCALE_PLACEHOLDER}/`; const resolveSearchPathTemplate = ( @@ -294,11 +330,17 @@ export const resolveSearch = ( const authorField = search.authorField ?? null; if (authorField !== null) assertSearchAuthorField(id, fields, authorField); + const imageField = search.imageField ?? null; + if (imageField !== null) { + assertSearchImageField(id, fields, exposed, imageField); + } + return { authorField, contentFields, descriptionField, enabled: true, + imageField, pathTemplate, titleField, }; diff --git a/packages/vitnode/src/content/public.test-d.ts b/packages/vitnode/src/content/public.test-d.ts index 0eb0766687..c866cad54b 100644 --- a/packages/vitnode/src/content/public.test-d.ts +++ b/packages/vitnode/src/content/public.test-d.ts @@ -8,7 +8,7 @@ import type { import { testPostContentType } from "@/tests/content-fixtures"; import type { - ContentPublicReadOptions, + ContentPublicItemReadOptions, ContentPublicService, ContentPublicSlugReadOptions, } from "./server/public-service"; @@ -180,7 +180,7 @@ describe("publicApi types", () => { // names a *language*, which chooses which translation the predicate runs // against and can never relax it. expectTypeOf().parameters.toEqualTypeOf< - [number, ContentPublicReadOptions?] + [number, ContentPublicItemReadOptions?] >(); expectTypeOf().parameters.toEqualTypeOf< [string, ContentPublicSlugReadOptions?] diff --git a/packages/vitnode/src/content/search.test.ts b/packages/vitnode/src/content/search.test.ts index d1d3e530d9..d4a7c64e45 100644 --- a/packages/vitnode/src/content/search.test.ts +++ b/packages/vitnode/src/content/search.test.ts @@ -72,6 +72,7 @@ describe("search configuration", () => { contentFields: ["excerpt", "body"], descriptionField: "excerpt", enabled: true, + imageField: null, pathTemplate: "/articles/{slug}", titleField: "title", }); @@ -83,6 +84,7 @@ describe("search configuration", () => { contentFields: [], descriptionField: null, enabled: false, + imageField: null, pathTemplate: "", titleField: "", }); diff --git a/packages/vitnode/src/content/search.ts b/packages/vitnode/src/content/search.ts index 018328f055..da961fc26a 100644 --- a/packages/vitnode/src/content/search.ts +++ b/packages/vitnode/src/content/search.ts @@ -70,6 +70,7 @@ export const contentSearchIndexedFieldNames = ( ...search.contentFields, publicApi.slugField, search.authorField, + search.imageField, ].filter((name): name is string => Boolean(name)), ), ]; diff --git a/packages/vitnode/src/content/server/delivery-routes.ts b/packages/vitnode/src/content/server/delivery-routes.ts index f5b9fe40c8..87e66ecb1c 100644 --- a/packages/vitnode/src/content/server/delivery-routes.ts +++ b/packages/vitnode/src/content/server/delivery-routes.ts @@ -99,6 +99,7 @@ export const buildContentDeliveryRoutes = < locale: z.string().nullable(), openGraph: zodSeo.nullable(), requestedLocale: z.string().nullable(), + hidden: z.boolean(), robots: z.object({ follow: z.boolean(), index: z.boolean() }).nullable(), seo: zodSeo, }); @@ -165,7 +166,12 @@ export const buildContentDeliveryRoutes = < locale: resolved.locale, }); - return c.json(resolution, 200); + return resolution.type === "content" && resolution.hidden + ? c.json(resolution, 200, { + "Cache-Control": "private, no-store", + "X-Robots-Tag": "noindex, nofollow", + }) + : c.json(resolution, 200); }, }); diff --git a/packages/vitnode/src/content/server/delivery-service.ts b/packages/vitnode/src/content/server/delivery-service.ts index 11cdefa3b1..61eff3e67d 100644 --- a/packages/vitnode/src/content/server/delivery-service.ts +++ b/packages/vitnode/src/content/server/delivery-service.ts @@ -30,6 +30,7 @@ import { contentLocalesMatch, normalizeContentLocale } from "../locale"; import { contentPublicHref } from "../public-url"; import { readDeliveryAlternates } from "./delivery-alternates"; import { readContentDeliverySitemapPage } from "./delivery-sitemap"; +import { findViewableHiddenBySlug } from "./hidden-access"; import { findContentLanguage, listContentLanguages } from "./language-resolver"; import { contentLocaleRouting } from "./locale-routing"; import { createContentSlugHistoryModel } from "./slug-history-model"; @@ -41,11 +42,12 @@ export interface ContentDeliveryMetadata { canonicalInternalPath: null | string; canonicalPath: null | string; canonicalUrl?: null | string; + hidden: boolean; /** Framework-neutral `hreflang`, ready for an adapter to translate. */ hreflang: ContentDeliveryHreflang; + /** Whether `locale` differs from `requestedLocale`. */ isFallback: boolean; - itemId: ContentId | null; /** The language this response is actually in. */ locale: null | string; @@ -172,10 +174,12 @@ export const createContentDeliveryService = < const metadataFor = async ( row: Record, { + hidden = false, itemId, origin, requestedLocale, }: { + hidden?: boolean; itemId: ContentId | null; origin?: string; requestedLocale: null | string; @@ -217,7 +221,10 @@ export const createContentDeliveryService = < locale, openGraph: contentDeliveryOpenGraph(definition, row), requestedLocale, - robots: contentDeliveryRobots(definition, row), + hidden, + robots: hidden + ? { follow: false, index: false } + : contentDeliveryRobots(definition, row), seo: contentDeliverySeo(definition, row), }; }; @@ -313,6 +320,24 @@ export const createContentDeliveryService = < }; } + const hidden = await findViewableHiddenBySlug(c, { + locale, + model, + pluginId, + slug, + }); + if (hidden && slugOf(definition, hidden) === slug) { + return { + ...(await metadataFor(hidden, { + hidden: true, + itemId: parseContentId(definition.idStrategy, hidden.id), + origin, + requestedLocale, + })), + type: "content", + }; + } + if (!definition.delivery.redirects.enabled) return { type: "not_found" }; const languageId = await historyLanguageId(requestedLocale); diff --git a/packages/vitnode/src/content/server/hidden-access.ts b/packages/vitnode/src/content/server/hidden-access.ts new file mode 100644 index 0000000000..81bbb142dd --- /dev/null +++ b/packages/vitnode/src/content/server/hidden-access.ts @@ -0,0 +1,146 @@ +import type { PgColumn, PgTable } from "drizzle-orm/pg-core"; +import type { Context } from "hono"; + +import { eq } from "drizzle-orm"; + +import type { SearchHiddenAccess } from "../../api/models/search"; +import type { ContentId } from "../ids"; +import type { RegisteredContentType } from "../registry"; +import type { AnyContentTypeDefinition } from "../types"; +import type { ContentModel } from "./model"; + +import { checkStaffPermissionOfUser } from "../../api/lib/check-staff-permission"; +import { CONTENT_PERMISSIONS } from "../const"; +import { parseContentId } from "../ids"; + +const toUserIds = (value: unknown): number[] => + (Array.isArray(value) ? value : [value]).filter( + (id): id is number => Number.isSafeInteger(id) && (id as number) > 0, + ); + +export const canEditContentType = async ( + c: Context, + { + definition, + pluginId, + }: { definition: AnyContentTypeDefinition; pluginId: string }, +): Promise => { + const user = c.get("user"); + if (!user) return false; + + return await checkStaffPermissionOfUser(c, user, { + module: definition.permissionModule, + permission: CONTENT_PERMISSIONS.edit, + plugin: pluginId, + type: "admin", + }); +}; + +const contentAuthorIds = async ( + c: Context, + model: ContentModel, + itemId: ContentId, +): Promise => { + const field = model.definition.search.authorField; + if (!field) return []; + + if (model.advanced.fields.includes(field)) { + const values = await model.advanced.load(itemId, c.get("db"), [field]); + + return toUserIds(values[field]); + } + + const columns = model.columns as Record; + const table: PgTable = model.table; + const column = columns[field]; + const id = columns.id; + if (!column || !id) return []; + + const [row] = await c + .get("db") + .select({ value: column }) + .from(table) + .where(eq(id, itemId)) + .limit(1); + + return toUserIds(row?.value); +}; + +export const canViewHiddenContent = async < + TDefinition extends AnyContentTypeDefinition, +>( + c: Context, + { + itemId, + model, + pluginId, + }: { itemId: ContentId; model: ContentModel; pluginId: string }, +): Promise => { + const user = c.get("user"); + if (!user) return false; + + const authors = await contentAuthorIds(c, model, itemId); + if (authors.includes(user.id)) return true; + + return await canEditContentType(c, { + definition: model.definition, + pluginId, + }); +}; + +export const findViewableHiddenBySlug = async < + TDefinition extends AnyContentTypeDefinition, +>( + c: Context, + { + locale, + model, + pluginId, + slug, + }: { + locale: string | undefined; + model: ContentModel; + pluginId: string; + slug: string; + }, +): Promise> => { + const { definition } = model; + if (!definition.visibility.enabled || !c.get("user")) return null; + + const row = await model + .publicService?.(c) + .findBySlug(slug, { fallback: true, hidden: true, locale }); + if (!row) return null; + + const values = row as Record; + const itemId = parseContentId(definition.idStrategy, values.id); + if (itemId === null) return null; + + return (await canViewHiddenContent(c, { itemId, model, pluginId })) + ? values + : null; +}; + +export const contentSearchHiddenAccess = async ( + c: Context, +): Promise => { + const user = c.get("user"); + if (!user) return undefined; + + const contentTypes: readonly RegisteredContentType[] = + c.get("core")?.contentTypes ?? []; + const editable = await Promise.all( + contentTypes + .filter( + ({ definition }) => + definition.search.enabled && definition.visibility.enabled, + ) + .map(async ({ definition, pluginId }) => + (await canEditContentType(c, { definition, pluginId })) + ? [definition.id] + : [], + ), + ); + + return { authorId: user.id, itemTypes: editable.flat() }; +}; diff --git a/packages/vitnode/src/content/server/index.ts b/packages/vitnode/src/content/server/index.ts index 25d5f4b412..019e600739 100644 --- a/packages/vitnode/src/content/server/index.ts +++ b/packages/vitnode/src/content/server/index.ts @@ -179,6 +179,7 @@ export { } from "./public-service"; export type { ContentPublicFindManyArgs, + ContentPublicItemReadOptions, ContentPublicReadOptions, ContentPublicService, ContentPublicSlugReadOptions, diff --git a/packages/vitnode/src/content/server/localized-public-service.ts b/packages/vitnode/src/content/server/localized-public-service.ts index 023fe76b6e..8858d798c3 100644 --- a/packages/vitnode/src/content/server/localized-public-service.ts +++ b/packages/vitnode/src/content/server/localized-public-service.ts @@ -36,6 +36,7 @@ import { createContentPublicProjector, } from "./public-service"; import { + contentPublicReadCondition, contentTranslationPublicationColumns, publicationColumns, publishedCondition, @@ -358,7 +359,10 @@ export const createContentLocalizedPublicService = < const readOne = async ( resolved: ResolvedLocale, condition: SQL | undefined, - { strict = false }: { strict?: boolean } = {}, + { + hidden = false, + strict = false, + }: { hidden?: boolean; strict?: boolean } = {}, ): Promise | null> => { // A strict read must not fall back - `findBySlug`. Dropping the fallback // language makes the join inner and the visibility test single-armed, so @@ -370,7 +374,7 @@ export const createContentLocalizedPublicService = < const [row] = await read( scope, conditions( - publishedCondition(basePublication), + contentPublicReadCondition(basePublication, { hidden }), visibleIn(scope), condition, ), @@ -389,7 +393,9 @@ export const createContentLocalizedPublicService = < const resolved = await resolveLocale(options?.locale); if (!resolved) return null; - return await readOne(resolved, eq(columns.id, id)); + return await readOne(resolved, eq(columns.id, id), { + hidden: options?.hidden, + }); }, findBySlug: async (slug, options) => { @@ -409,11 +415,14 @@ export const createContentLocalizedPublicService = < : eq(columns[slugField], slug); const row = await readOne(resolved, slugIn(resolved.requested.id), { + hidden: options?.hidden, strict: true, }); if (row || !options?.fallback || !resolved.fallbackTo) return row; - return await readOne(resolved, slugIn(resolved.fallbackTo.id)); + return await readOne(resolved, slugIn(resolved.fallbackTo.id), { + hidden: options?.hidden, + }); }, findMany: async ({ filters = {}, locale, orderBy, query = {} } = {}) => { diff --git a/packages/vitnode/src/content/server/public-routes.ts b/packages/vitnode/src/content/server/public-routes.ts index 751eb0fc33..72a9e9401c 100644 --- a/packages/vitnode/src/content/server/public-routes.ts +++ b/packages/vitnode/src/content/server/public-routes.ts @@ -41,6 +41,7 @@ import { import { publicOrderableColumns } from "../registry"; import { buildContentDeliveryRoutes } from "./delivery-routes"; import { resolveContentPublicRowFiles } from "./files"; +import { findViewableHiddenBySlug } from "./hidden-access"; import { findContentLanguage, listContentLanguages } from "./language-resolver"; import { contentPreviewSecret } from "./preview-link"; import { verifyContentPreviewToken } from "./preview-token"; @@ -487,13 +488,32 @@ export const buildContentPublicRoutes = < // that is not published in this language and a typo are all the same 404. // A 403 would confirm the record exists, which is the one thing a draft // URL must not do - and so would a 404 that only some of them produced. - const row = await service(c).findBySlug(c.req.param("slug"), { + const slug = c.req.param("slug"); + const row = await service(c).findBySlug(slug, { fallback: true, locale: resolved.locale, }); - if (!row) throw notFound(); + if (row) { + return c.json( + row, + 200, + localeHeaders(resolved.locale, resolved.source), + ); + } - return c.json(row, 200, localeHeaders(resolved.locale, resolved.source)); + const hidden = await findViewableHiddenBySlug(c, { + locale: resolved.locale, + model, + pluginId, + slug, + }); + if (!hidden) throw notFound(); + + return c.json(hidden, 200, { + "Cache-Control": "private, no-store", + "X-Robots-Tag": "noindex, nofollow", + ...localeHeaders(resolved.locale, resolved.source), + }); }, }); diff --git a/packages/vitnode/src/content/server/public-service.ts b/packages/vitnode/src/content/server/public-service.ts index 46c98ac0d9..579aa810ff 100644 --- a/packages/vitnode/src/content/server/public-service.ts +++ b/packages/vitnode/src/content/server/public-service.ts @@ -31,7 +31,11 @@ import { isContentReferenceCollection, splitContentFieldPath } from "../paths"; import { publicOrderableColumns } from "../registry"; import { groupPublicLeafPaths } from "../schemas"; import { createContentPublicRowHydrator } from "./public-row-hydration"; -import { publicationColumns, publishedCondition } from "./publication"; +import { + contentPublicReadCondition, + publicationColumns, + publishedCondition, +} from "./publication"; import { buildFilterCondition, buildOrderColumn, @@ -45,7 +49,11 @@ export interface ContentPublicReadOptions { locale?: string; } -export interface ContentPublicSlugReadOptions extends ContentPublicReadOptions { +export interface ContentPublicItemReadOptions extends ContentPublicReadOptions { + hidden?: boolean; +} + +export interface ContentPublicSlugReadOptions extends ContentPublicItemReadOptions { fallback?: boolean; } @@ -72,7 +80,7 @@ export interface ContentPublicService { /** `null` unless the row exists *and* is published. */ findById: ( id: ContentIdOf, - options?: ContentPublicReadOptions, + options?: ContentPublicItemReadOptions, ) => Promise | null>; findBySlug: ( @@ -305,12 +313,13 @@ export const createContentPublicService = < const readOne = async ( condition: SQL, + options: ContentPublicItemReadOptions = {}, ): Promise | null> => { const [row] = await c .get("db") .select(selection()) .from(table) - .where(and(publishedCondition(published), condition)) + .where(and(contentPublicReadCondition(published, options), condition)) .limit(1); if (!row) return null; @@ -321,10 +330,11 @@ export const createContentPublicService = < }; return { - findById: async id => await readOne(eq(primaryCursor, id)), + findById: async (id, options) => + await readOne(eq(primaryCursor, id), options), - findBySlug: async slug => - await readOne(eq(columns[publicApi.slugField], slug)), + findBySlug: async (slug, options) => + await readOne(eq(columns[publicApi.slugField], slug), options), findMany: async ({ filters = {}, orderBy, query = {} } = {}) => { const conditions = [ diff --git a/packages/vitnode/src/content/server/publication.ts b/packages/vitnode/src/content/server/publication.ts index f4b7d3169d..17d685f1d2 100644 --- a/packages/vitnode/src/content/server/publication.ts +++ b/packages/vitnode/src/content/server/publication.ts @@ -61,6 +61,22 @@ export const publishedCondition = ( columns.hiddenAt === undefined ? undefined : isNull(columns.hiddenAt), ); +export const hiddenPublishedCondition = ( + columns: PublicationColumns, +): SQL | undefined => + columns.hiddenAt === undefined + ? sql`false` + : and( + publishedCondition({ ...columns, hiddenAt: undefined }), + isNotNull(columns.hiddenAt), + ); + +export const contentPublicReadCondition = ( + columns: PublicationColumns, + { hidden = false }: { hidden?: boolean } = {}, +): SQL | undefined => + hidden ? hiddenPublishedCondition(columns) : publishedCondition(columns); + export const contentTranslationPublicationColumns = ( definition: AnyContentTypeDefinition, translationColumns: Record, diff --git a/packages/vitnode/src/content/server/search-document.test.ts b/packages/vitnode/src/content/server/search-document.test.ts index 2f5563703a..d79788d9b7 100644 --- a/packages/vitnode/src/content/server/search-document.test.ts +++ b/packages/vitnode/src/content/server/search-document.test.ts @@ -48,6 +48,7 @@ describe("content search document", () => { authorIds: [], content: "A short excerpt.\n\nThe body of the post.", createdAt: PUBLISHED_AT, + isHidden: false, isPublic: true, itemId: 7, itemType: "test.searchable", diff --git a/packages/vitnode/src/content/server/search-document.ts b/packages/vitnode/src/content/server/search-document.ts index 42d0eec0c2..0ede08ebf9 100644 --- a/packages/vitnode/src/content/server/search-document.ts +++ b/packages/vitnode/src/content/server/search-document.ts @@ -12,6 +12,7 @@ import { readContentPath, splitContentFieldPath } from "../paths"; import { isRichTextDocument } from "../rich-text/document"; import { richTextToPlainText } from "../rich-text/plain-text"; import { contentSearchUrl } from "../search"; +import { isContentHidden } from "../visibility"; /** Collapses whitespace so a multi-line value cannot break a result heading. */ const normalize = (value: unknown): string => { @@ -55,6 +56,14 @@ const toTimestamp = (value: unknown): Date | null | string | undefined => { return value === null ? null : undefined; }; +export const isContentRowHidden = (row: object): boolean => { + const values = row as Record; + + return ( + isContentHidden(values) && isContentRowPublic({ ...values, hiddenAt: null }) + ); +}; + export const isContentRowPublic = (row: object): boolean => { const values = row as Record; @@ -73,15 +82,22 @@ const toAuthorIds = (value: unknown): number[] => [ ), ]; +const toFileId = (value: unknown): number | undefined => + typeof value === "number" && Number.isSafeInteger(value) && value > 0 + ? value + : undefined; + export const contentSearchDocument = ( definition: AnyContentTypeDefinition, row: object, { + isHidden = isContentRowHidden(row), isPublic = isContentRowPublic(row), locale, pluginId, routing, }: { + isHidden?: boolean; isPublic?: boolean; locale?: string; pluginId?: string; @@ -101,14 +117,15 @@ export const contentSearchDocument = ( const title = normalize(readSearchValue(values, search.titleField)); if (title === "") return null; - const url = isPublic - ? contentSearchUrl({ - definition, - locale, - routing, - slug: normalize(values[publicApi.slugField]), - }) - : null; + const url = + isPublic || isHidden + ? contentSearchUrl({ + definition, + locale, + routing, + slug: normalize(values[publicApi.slugField]), + }) + : null; if (isPublic && url === null) return null; // A published row always carries `publishedAt`, so this resolves to the @@ -140,6 +157,10 @@ export const contentSearchDocument = ( .filter(value => value !== "") .join("\n\n"), createdAt, + ...(search.imageField === null + ? {} + : { imageId: toFileId(values[search.imageField]) ?? null }), + isHidden: !isPublic && isHidden && url !== null, isPublic, itemId, // Content type ids are globally unique and already namespaced as @@ -177,17 +198,21 @@ export const contentTranslationSearchDocument = ( const baseValues = base as Record; const translationValues = translation as Record; - const isPublic = isContentTranslationPubliclyVisible({ - base: { - hiddenAt: toTimestamp(baseValues.hiddenAt), - publishedAt: toTimestamp(baseValues.publishedAt), - status: normalize(baseValues.status) || undefined, - }, - translation: { - publishedAt: toTimestamp(translationValues.publishedAt), - status: normalize(translationValues.status) || undefined, - }, - }); + const translationVisible = (hiddenAt: Date | null | string | undefined) => + isContentTranslationPubliclyVisible({ + base: { + hiddenAt, + publishedAt: toTimestamp(baseValues.publishedAt), + status: normalize(baseValues.status) || undefined, + }, + translation: { + publishedAt: toTimestamp(translationValues.publishedAt), + status: normalize(translationValues.status) || undefined, + }, + }); + const isPublic = translationVisible(toTimestamp(baseValues.hiddenAt)); + const isHidden = + !isPublic && isContentHidden(baseValues) && translationVisible(null); const { localizedFields } = partitionContentFields(definition.fields); // The localized half wins, and only for declared localized fields: a @@ -205,6 +230,7 @@ export const contentTranslationSearchDocument = ( merged.updatedAt = translationValues.updatedAt ?? baseValues.updatedAt; return contentSearchDocument(definition, merged, { + isHidden, isPublic, locale, pluginId, diff --git a/packages/vitnode/src/content/server/search-sync.test.ts b/packages/vitnode/src/content/server/search-sync.test.ts index a3e8801c19..9a54c198c1 100644 --- a/packages/vitnode/src/content/server/search-sync.test.ts +++ b/packages/vitnode/src/content/server/search-sync.test.ts @@ -529,7 +529,7 @@ describe("hide and unhide", () => { version: 3, }; - it("rewrites a hidden record's document as private, with no URL", async () => { + it("rewrites a hidden record's document as private and hidden, keeping its URL for authors and editors", async () => { const { c, search } = searchOf(); const outcome = await syncContentSearch(c, testHideablePostContentType, { @@ -540,9 +540,13 @@ describe("hide and unhide", () => { expect(outcome.action).toBe("upsert"); expect(search.index).toHaveBeenCalledWith( - expect.objectContaining({ isPublic: false, itemId: 9 }), + expect.objectContaining({ + isHidden: true, + isPublic: false, + itemId: 9, + url: "/hideable-posts/hidden-world", + }), ); - expect(search.index.mock.calls[0][0]).not.toHaveProperty("url"); }); it("makes it public again on unhide", async () => { diff --git a/packages/vitnode/src/content/types.ts b/packages/vitnode/src/content/types.ts index d89abb50ca..8e62a0b035 100644 --- a/packages/vitnode/src/content/types.ts +++ b/packages/vitnode/src/content/types.ts @@ -1285,6 +1285,7 @@ export interface ContentSearchConfig< contentFields: readonly [TText, ...TText[]]; /** Prepended to the indexed body so it shows up in result excerpts. */ descriptionField?: TDescription; + imageField?: string; pathTemplate: string; /** The result heading. Weighted above the body by the index. */ titleField: TTitle; @@ -1310,6 +1311,7 @@ export interface ResolvedContentSearchConfig< contentFields: string[]; descriptionField: null | string; enabled: TEnabled; + imageField: null | string; pathTemplate: string; titleField: string; } diff --git a/packages/vitnode/src/database/search.ts b/packages/vitnode/src/database/search.ts index 529caecd11..fefb419f4c 100644 --- a/packages/vitnode/src/database/search.ts +++ b/packages/vitnode/src/database/search.ts @@ -4,6 +4,7 @@ import { sql } from "drizzle-orm"; import { camelCase, customType, index, unique } from "drizzle-orm/pg-core"; import { CONTENT_ID_KEY_LENGTH } from "../content/const"; +import { core_files } from "./files"; // Drizzle ships no native `tsvector` type, so we declare it once and reuse it // for the generated full-text-search column below. The column and its GIN index @@ -92,6 +93,10 @@ export const core_search_index = camelCase.table.withRLS( containerId: t.integer(), url: t.text(), isPublic: t.boolean().notNull().default(true), + isHidden: t.boolean().notNull().default(false), + imageId: t + .integer() + .references(() => core_files.id, { onDelete: "set null" }), metadata: t.jsonb().$type>().notNull().default({}), createdAt: t.timestamp().notNull().defaultNow(), updatedAt: t.timestamp(), @@ -109,5 +114,6 @@ export const core_search_index = camelCase.table.withRLS( index("core_search_index_item_type_idx").on(t.itemType), index("core_search_index_language_code_idx").on(t.languageCode), index("core_search_index_is_public_idx").on(t.isPublic), + index("core_search_index_image_id_idx").on(t.imageId), ], ); diff --git a/packages/vitnode/src/locales/en.json b/packages/vitnode/src/locales/en.json index 4a558b2b28..e2c5daebbf 100644 --- a/packages/vitnode/src/locales/en.json +++ b/packages/vitnode/src/locales/en.json @@ -74,6 +74,8 @@ "loadMore": "Load more", "loading": "Loading…", "draft": "Draft", + "hidden": "{type} hidden", + "more_authors": "{count, plural, one {# other} other {# others}}", "sortBy": "Sort by", "sort": { "relevance": "Relevance", @@ -81,7 +83,7 @@ "oldest": "Oldest" }, "types": { - "blog_post": "Post", + "blog_post": "Article", "unknown": "Content" }, "userTab": { @@ -1504,6 +1506,7 @@ "hidden": "Hidden", "visible": "Visible", "hidden_since": "Hidden since {date}", + "hidden_record": "{name} hidden", "hidden_desc": "Hidden from the public site, search and the sitemap. Publishing a hidden record does not make it public.", "publish_note": "Publishing a hidden record does not make it public.", "conflict": { @@ -1688,15 +1691,16 @@ }, "status": { "saving": "Saving…", - "saved": "Draft saved {time}", - "saved_just_now": "Draft saved just now", - "saved_at": "Draft saved at {time}", - "unsaved": "Unsaved changes", - "failed": "Draft not saved", + "saved": "Autosaved {time}", + "saved_just_now": "Autosaved just now", + "saved_at": "Autosaved at {time}", + "autosaved": "Autosaved", + "autosaved_hint": "Your edits are saved automatically, but readers see them only after you save changes.", + "failed": "Autosave failed", "discard": "Discard", - "discard_label": "Discard unsaved changes", - "discard_title": "Discard unsaved changes?", - "discard_desc": "The record goes back to how it was last saved. Unsaved changes from everyone editing it are discarded too.", + "discard_label": "Discard autosaved changes", + "discard_title": "Discard autosaved changes?", + "discard_desc": "The record goes back to how it was last saved. Autosaved changes from everyone editing it are discarded too.", "discard_confirm": "Discard changes", "discard_failed": "The changes could not be discarded. Try again." }, diff --git a/packages/vitnode/src/tanstack/admin/content/list.tsx b/packages/vitnode/src/tanstack/admin/content/list.tsx index 6e2d63d2e8..e8ad0d9db0 100644 --- a/packages/vitnode/src/tanstack/admin/content/list.tsx +++ b/packages/vitnode/src/tanstack/admin/content/list.tsx @@ -170,11 +170,15 @@ const ContentListTable = ({ columnSpecs, labels: { empty: t("table.empty_value"), + ...(definition.visibility.enabled + ? { + hidden: t("visibility.hidden_record", { + name: labels.singular, + }), + } + : {}), status: { draft: t("status.draft"), - ...(definition.visibility.enabled - ? { hidden: t("visibility.hidden") } - : {}), published: t("status.published"), }, }, diff --git a/packages/vitnode/src/views/admin/views/content/actions/visibility-dialog.tsx b/packages/vitnode/src/views/admin/views/content/actions/visibility-dialog.tsx index 43f54ce78b..b623e59e3d 100644 --- a/packages/vitnode/src/views/admin/views/content/actions/visibility-dialog.tsx +++ b/packages/vitnode/src/views/admin/views/content/actions/visibility-dialog.tsx @@ -1,4 +1,4 @@ -import { EyeClosedIcon, ViewIcon } from "lucide-react"; +import { EyeOffIcon, ViewIcon } from "lucide-react"; import { useTranslations } from "use-intl"; import type { ContentVisibilityAction } from "@/content/visibility"; @@ -10,7 +10,7 @@ export const CONTENT_VISIBILITY_ICONS: Record< ContentVisibilityAction, React.ReactNode > = { - hide: , + hide: , unhide: , }; diff --git a/packages/vitnode/src/views/admin/views/content/form/index.ts b/packages/vitnode/src/views/admin/views/content/form/index.ts index c4585c2b70..d1bd416266 100644 --- a/packages/vitnode/src/views/admin/views/content/form/index.ts +++ b/packages/vitnode/src/views/admin/views/content/form/index.ts @@ -38,7 +38,9 @@ export { ContentFormRemainingFields, ContentFormStatus, } from "./primitives"; +export { ContentFormPublicationLabel } from "./publication-label"; export { ContentFormPublication } from "./publication-status"; +export { ContentFormSaveMenu } from "./save-menu"; export { ContentFormFieldSkeleton, type ContentFormSkeletonControl, @@ -47,7 +49,7 @@ export { ContentFormStatusSwitch } from "./status-switch"; export { useTranslationFreshness } from "./translation-freshness"; export { useContentFormValues, useSetContentFormValue } from "./values"; export { - ContentFormHiddenBadge, + ContentFormHiddenIcon, ContentFormHiddenNotice, ContentFormVisibilityToggle, useContentFormHidden, diff --git a/packages/vitnode/src/views/admin/views/content/form/publication-label.tsx b/packages/vitnode/src/views/admin/views/content/form/publication-label.tsx new file mode 100644 index 0000000000..eb9ad674d7 --- /dev/null +++ b/packages/vitnode/src/views/admin/views/content/form/publication-label.tsx @@ -0,0 +1,57 @@ +import { cn } from "cn"; +import { useFormatter, useTranslations } from "use-intl"; + +import { TooltipWithContent } from "@/components/ui/tooltip"; +import { isContentPublished } from "@/content/publication"; + +import { useContentForm } from "./context"; + +export const ContentFormPublicationLabel = ({ + className, +}: { + className?: string; +}) => { + const t = useTranslations("core.content.status"); + const format = useFormatter(); + const { mode, publication, skeleton } = useContentForm(); + + if (!publication.enabled || mode === "create" || skeleton) return null; + + const published = isContentPublished(publication.status); + const publishedAt = + typeof publication.publishedAt === "string" + ? new Date(publication.publishedAt) + : null; + + return ( + + + + {published ? t("published") : t("draft")} + + + ); +}; diff --git a/packages/vitnode/src/views/admin/views/content/form/save-menu.tsx b/packages/vitnode/src/views/admin/views/content/form/save-menu.tsx new file mode 100644 index 0000000000..9c83708cc8 --- /dev/null +++ b/packages/vitnode/src/views/admin/views/content/form/save-menu.tsx @@ -0,0 +1,183 @@ +import { + ChevronDownIcon, + FileClockIcon, + SaveIcon, + SendIcon, +} from "lucide-react"; +import React from "react"; +import { useTranslations } from "use-intl"; + +import type { ContentPublicationAction } from "@/content/publication"; +import type { ContentVisibilityAction } from "@/content/visibility"; + +import { ConfirmActionAlertDialog } from "@/components/confirm-action/confirm-action-alert-dialog"; +import { AutoFormSubmitButton } from "@/components/form/auto-form"; +import { Button } from "@/components/ui/button"; +import { ButtonGroup } from "@/components/ui/button-group"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { contentPublicationTransition } from "@/content/publication"; +import { contentVisibilityTransition } from "@/content/visibility"; + +import { + CONTENT_VISIBILITY_ICONS, + ContentVisibilityDialog, +} from "../actions/visibility-dialog"; +import { useContentForm } from "./context"; +import { ContentFormSubmit } from "./primitives"; +import { ContentFormButtonSkeleton } from "./skeleton"; +import { useContentFormHidden } from "./visibility"; + +const PUBLICATION_ICONS: Record = { + publish: , + unpublish: , +}; + +type ContentFormMenuAction = ContentPublicationAction | ContentVisibilityAction; + +const PublicationDialog = ({ + action, + onOpenChange, + open, + triggerRef, +}: { + action: ContentPublicationAction; + onOpenChange: (open: boolean) => void; + open: boolean; + triggerRef: React.RefObject; +}) => { + const t = useTranslations("core.content"); + const { publication, singular, title } = useContentForm(); + const hidden = useContentFormHidden(); + const description = t.rich(`${action}.desc`, { + title: () => ( + {title ?? singular} + ), + }); + + return ( +

- - diff --git a/packages/vitnode/src/views/admin/views/content/form/visibility.test.tsx b/packages/vitnode/src/views/admin/views/content/form/visibility.test.tsx index 1089a42cba..6a9e7d9a2a 100644 --- a/packages/vitnode/src/views/admin/views/content/form/visibility.test.tsx +++ b/packages/vitnode/src/views/admin/views/content/form/visibility.test.tsx @@ -125,14 +125,12 @@ describe("the editor's status", () => { ).toBeNull(); }); - it("shows the badge beside the status switch, and warns before publishing", async () => { + it("warns before publishing a hidden record from the status switch", async () => { const { publish } = renderForm(, { publication: { status: "draft" }, visibility: { hiddenAt: HIDDEN_AT }, }); - expect(screen.getByText("core.content.visibility.hidden")).toBeTruthy(); - fireEvent.click( screen.getByRole("radio", { name: "core.content.status.published" }), ); diff --git a/packages/vitnode/src/views/admin/views/content/form/visibility.tsx b/packages/vitnode/src/views/admin/views/content/form/visibility.tsx index 68ba241d2b..000464bc36 100644 --- a/packages/vitnode/src/views/admin/views/content/form/visibility.tsx +++ b/packages/vitnode/src/views/admin/views/content/form/visibility.tsx @@ -1,7 +1,6 @@ -import { useFormatter, useTranslations } from "use-intl"; +import { useTranslations } from "use-intl"; import { Button } from "@/components/ui/button"; -import { TooltipWithContent } from "@/components/ui/tooltip"; import { contentVisibilityTransition, isContentHidden, @@ -11,7 +10,7 @@ import { CONTENT_VISIBILITY_ICONS, ContentVisibilityDialog, } from "../actions/visibility-dialog"; -import { ContentHiddenBadge } from "../lib/hidden-badge"; +import { ContentHiddenIcon } from "../lib/hidden-badge"; import { useContentForm } from "./context"; /** Whether the record this form edits is hidden right now. */ @@ -25,43 +24,24 @@ export const useContentFormHidden = (): boolean => { ); }; -/** - * "Hidden", beside the status - with since when, for anyone who hovers it. - * Nothing for a visible record, a create, or a content type without visibility. - */ -export const ContentFormHiddenBadge = () => { +export const ContentFormHiddenIcon = ({ + className, +}: { + className?: string; +}) => { const t = useTranslations("core.content.visibility"); - const format = useFormatter(); - const { visibility } = useContentForm(); + const { singular, visibility } = useContentForm(); const hidden = useContentFormHidden(); if (!hidden) return null; - const since = - typeof visibility?.hiddenAt === "string" - ? new Date(visibility.hiddenAt) - : null; - const badge = ( - ); - - if (!since || Number.isNaN(since.getTime())) return badge; - - return ( - - {badge} - - ); }; /** diff --git a/packages/vitnode/src/views/admin/views/content/lib/hidden-badge.tsx b/packages/vitnode/src/views/admin/views/content/lib/hidden-badge.tsx index 2c1c5e0a84..d6194ebed3 100644 --- a/packages/vitnode/src/views/admin/views/content/lib/hidden-badge.tsx +++ b/packages/vitnode/src/views/admin/views/content/lib/hidden-badge.tsx @@ -1,5 +1,6 @@ -import { EyeClosedIcon } from "lucide-react"; +import { EyeOffIcon } from "lucide-react"; +import { HiddenIcon } from "@/components/hidden-icon"; import { Badge } from "@/components/ui/badge"; import { isContentHidden } from "@/content/visibility"; @@ -22,8 +23,21 @@ export const ContentHiddenBadge = ({ return ( - + {label} ); }; + +export const ContentHiddenIcon = ({ + className, + label, + row, +}: { + className?: string; + label: string; + row: { hiddenAt?: unknown }; +}) => + isContentHidden(row) ? ( + + ) : null; diff --git a/packages/vitnode/src/views/admin/views/content/live/root.tsx b/packages/vitnode/src/views/admin/views/content/live/root.tsx index 7ec74d85fd..86e9dc3143 100644 --- a/packages/vitnode/src/views/admin/views/content/live/root.tsx +++ b/packages/vitnode/src/views/admin/views/content/live/root.tsx @@ -22,7 +22,9 @@ import { useContentLiveSession } from "./use-session"; import { contentDraftDiffers, contentDraftMovedReferences, + contentDraftUpdatedAt, contentDraftValue, + laterContentTime, mergeContentDraft, } from "./values"; @@ -320,7 +322,11 @@ export const ContentLiveRoot = ({ richText, session, spec, - status: { ...status, dirty: contentDraftDiffers(row, rows, drafts) }, + status: { + ...status, + dirty: contentDraftDiffers(row, rows, drafts), + savedAt: laterContentTime(status.savedAt, contentDraftUpdatedAt(drafts)), + }, }; return ( diff --git a/packages/vitnode/src/views/admin/views/content/live/status.test.tsx b/packages/vitnode/src/views/admin/views/content/live/status.test.tsx index 2ed25c5e8d..43dc376e3d 100644 --- a/packages/vitnode/src/views/admin/views/content/live/status.test.tsx +++ b/packages/vitnode/src/views/admin/views/content/live/status.test.tsx @@ -114,7 +114,6 @@ describe("ContentLiveStatus", () => { ); expect(screen.getByText("core.content.live.status.failed")).toBeTruthy(); - expect(screen.getByText("core.content.live.status.unsaved")).toBeTruthy(); }); it("discards unsaved changes once the person confirms", async () => { diff --git a/packages/vitnode/src/views/admin/views/content/live/status.tsx b/packages/vitnode/src/views/admin/views/content/live/status.tsx index 767465855b..5bce6c70df 100644 --- a/packages/vitnode/src/views/admin/views/content/live/status.tsx +++ b/packages/vitnode/src/views/admin/views/content/live/status.tsx @@ -5,6 +5,7 @@ import { useFormatter, useNow, useTranslations } from "use-intl"; import { ConfirmActionAlertDialog } from "@/components/confirm-action/confirm-action-alert-dialog"; import { Button } from "@/components/ui/button"; import { Spinner } from "@/components/ui/spinner"; +import { TooltipWithContent } from "@/components/ui/tooltip"; import { useContentLive } from "./context"; @@ -94,20 +95,26 @@ export const ContentLiveStatus = ({ className }: { className?: string }) => { ) : failed ? ( {t("failed")} + ) : dirty ? ( + + + + + {savedAt ? : t("autosaved")} + + + ) : savedAt ? ( - + ) : null} - {dirty ? ( - - - {t("unsaved")} - - ) : null}

{dirty ? : null}
diff --git a/packages/vitnode/src/views/admin/views/content/live/values.ts b/packages/vitnode/src/views/admin/views/content/live/values.ts index 54d7c2ff41..e42972b4a4 100644 --- a/packages/vitnode/src/views/admin/views/content/live/values.ts +++ b/packages/vitnode/src/views/admin/views/content/live/values.ts @@ -91,6 +91,28 @@ export const mergeContentDraft = ( }; }; +export const laterContentTime = ( + a: null | string, + b: null | string, +): null | string => { + if (a === null) return b; + if (b === null) return a; + + return Date.parse(b) > Date.parse(a) ? b : a; +}; + +export const contentDraftUpdatedAt = ( + drafts: ContentDrafts | null, +): null | string => + drafts + ? [drafts.shared, ...Object.values(drafts.translations)].reduce< + null | string + >( + (latest, draft) => laterContentTime(latest, draft?.updatedAt ?? null), + null, + ) + : null; + export const contentDraftDiffers = ( data: Record, translations: readonly TranslationValues[], diff --git a/packages/vitnode/src/views/admin/views/content/table/cells.test.tsx b/packages/vitnode/src/views/admin/views/content/table/cells.test.tsx index 22689d44d8..f3369eb453 100644 --- a/packages/vitnode/src/views/admin/views/content/table/cells.test.tsx +++ b/packages/vitnode/src/views/admin/views/content/table/cells.test.tsx @@ -139,54 +139,46 @@ describe("ContentCell", () => { expect(screen.getByText(COVER.name)).toBeTruthy(); }); - describe("status with visibility", () => { - const STATUS: ContentColumnSpec = { - kind: "publication", - label: "Status", - name: "status", + describe("title with visibility", () => { + const TITLE: ContentColumnSpec = { + kind: "text", + label: "Title", + name: "title", + title: true, }; - const labels = { draft: "Draft", hidden: "Hidden", published: "Published" }; + const HIDDEN_AT = "2026-10-01T09:00:00.000Z"; - it("says Hidden in words beside a published record readers cannot reach", () => { + it("prefixes a hidden record's title with an icon named for it", () => { render( , ); - expect(screen.getByText("Published")).toBeTruthy(); - expect(screen.getByText("Hidden")).toBeTruthy(); + expect(screen.getByText("Hello world")).toBeTruthy(); + expect(screen.getByRole("img", { name: "Article hidden" })).toBeTruthy(); }); it.each([ - [ - "a visible record", - { hiddenAt: null, status: "published" as const }, - labels, - ], - [ - "a content type without visibility", - { hiddenAt: "2026-10-01T09:00:00.000Z", status: "published" as const }, - { draft: "Draft", published: "Published" }, - ], - ])("shows no badge for %s", (_name, overrides, statusLabels) => { + ["a visible record", { hiddenAt: null }, "Article hidden"], + ["a content type without visibility", { hiddenAt: HIDDEN_AT }, undefined], + ])("shows no icon for %s", (_name, overrides, hiddenLabel) => { render( , ); - expect(screen.getByText("Published")).toBeTruthy(); - expect(screen.queryByText("Hidden")).toBeNull(); + expect(screen.getByText("Hello world")).toBeTruthy(); + expect(screen.queryByRole("img", { name: "Article hidden" })).toBeNull(); }); }); }); diff --git a/packages/vitnode/src/views/admin/views/content/table/cells.tsx b/packages/vitnode/src/views/admin/views/content/table/cells.tsx index b0cbcbbc8a..369dc2608a 100644 --- a/packages/vitnode/src/views/admin/views/content/table/cells.tsx +++ b/packages/vitnode/src/views/admin/views/content/table/cells.tsx @@ -23,7 +23,7 @@ import { isContentPublished } from "@/content/publication"; import { isRichTextDocument } from "@/content/rich-text/document"; import { richTextToPlainText } from "@/content/rich-text/plain-text"; -import { ContentHiddenBadge } from "../lib/hidden-badge"; +import { ContentHiddenIcon } from "../lib/hidden-badge"; export interface ContentRowData extends Record { files?: Record; @@ -152,16 +152,14 @@ const ContentReferenceList = ({ ); }; -/** The publication badge's words, and the visibility badge's beside it. */ export interface ContentStatusLabels { draft: string; - /** `core.content.visibility.hidden`. Absent, a hidden row shows no badge. */ - hidden?: string; published: string; } interface ContentCellProps { emptyLabel: string; + hiddenLabel?: string; row: ContentRowData; spec: ContentColumnSpec; statusLabels: ContentStatusLabels; @@ -236,7 +234,8 @@ const ContentCellValue = ({ case "publication": { const published = isContentPublished(value); - const badge = ( + + return ( {published ? ( @@ -246,19 +245,6 @@ const ContentCellValue = ({ {published ? statusLabels.published : statusLabels.draft} ); - - // "Published" alone would be a lie for a hidden record: readers get a 404. - return statusLabels.hidden === undefined ? ( - badge - ) : ( - - {badge} - - - ); } case "richText": @@ -284,14 +270,28 @@ const ContentCellValue = ({ } }; +const ContentCellTitle = (props: ContentCellProps) => + props.spec.title === true && props.hiddenLabel !== undefined ? ( + + + + + ) : ( + + ); + export const ContentCell = (props: ContentCellProps) => props.spec.thumbnail === undefined ? ( - + ) : ( - + ); diff --git a/packages/vitnode/src/views/admin/views/content/table/columns.tsx b/packages/vitnode/src/views/admin/views/content/table/columns.tsx index 102ff6f8d2..b80b272485 100644 --- a/packages/vitnode/src/views/admin/views/content/table/columns.tsx +++ b/packages/vitnode/src/views/admin/views/content/table/columns.tsx @@ -66,7 +66,8 @@ export const contentRowTitle = ( export interface ContentCellLabels { /** `core.content.table.empty_value` - a column this row has no value for. */ empty: string; - /** `core.content.status.*`, for the publication badge - and "Hidden" beside it. */ + hidden?: string; + /** `core.content.status.*`, for the publication badge. */ status: ContentStatusLabels; } @@ -112,6 +113,7 @@ export const buildContentTableColumns = ({ ) : ( = { - blog_post: { icon: FileTextIcon, labelKey: "types.blog_post" }, + "blog.post": { icon: FileTextIcon, labelKey: "types.blog_post" }, }; const fallback: SearchTypeRenderer = { diff --git a/packages/vitnode/src/views/search/search-feed-content.tsx b/packages/vitnode/src/views/search/search-feed-content.tsx index d815249190..81bd5af1c2 100644 --- a/packages/vitnode/src/views/search/search-feed-content.tsx +++ b/packages/vitnode/src/views/search/search-feed-content.tsx @@ -2,10 +2,11 @@ import { useInfiniteQuery } from "@tanstack/react-query"; import { Link } from "@tanstack/react-router"; import { cn } from "cn"; import React from "react"; -import { useTranslations } from "use-intl"; +import { useFormatter, useTranslations } from "use-intl"; import { Avatar } from "@/components/avatar"; import { DateFormat } from "@/components/date-format"; +import { HiddenIcon } from "@/components/hidden-icon"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; @@ -13,7 +14,11 @@ import { Skeleton } from "@/components/ui/skeleton"; import { TooltipWithContent } from "@/components/ui/tooltip"; import type { SearchFeedQueryOptions } from "./search-feed-query"; -import type { SearchFeedPage, SearchResultItem } from "./types"; +import type { + SearchFeedPage, + SearchResultAuthor, + SearchResultItem, +} from "./types"; import { getSearchTypeRenderer } from "./registry"; @@ -127,6 +132,95 @@ const ItemTitle = ({ item }: { item: SearchResultItem }) => <>{item.title} ); +const ItemHiddenIcon = ({ + className, + focusable, + item, +}: { + className?: string; + focusable?: boolean; + item: SearchResultItem; +}) => { + const t = useTranslations("core.search"); + if (!item.isHidden) return null; + + return ( + + ); +}; + +const MAX_SHOWN_AUTHORS = 3; + +const authorsOf = (item: SearchResultItem): SearchResultAuthor[] => + item.authors?.length ? item.authors : item.author ? [item.author] : []; + +const AuthorNames = ({ authors }: { authors: SearchResultAuthor[] }) => { + const t = useTranslations("core.search"); + const format = useFormatter(); + const shown = + authors.length > MAX_SHOWN_AUTHORS + ? authors.slice(0, MAX_SHOWN_AUTHORS - 1) + : authors; + const rest = authors.length - shown.length; + + return ( + + {format.list([ + ...shown.map(author => author.name), + ...(rest > 0 ? [t("more_authors", { count: rest })] : []), + ])} + + ); +}; + +const AuthorAvatars = ({ + authors, + size, +}: { + authors: SearchResultAuthor[]; + size: number; +}) => ( + + {authors.slice(0, MAX_SHOWN_AUTHORS).map((author, index) => ( + 0 && "-ms-1.5")} + key={author.id} + size={size} + user={author} + /> + ))} + +); + +const ResultImage = ({ + className, + image, +}: { + className?: string; + image: SearchResultItem["image"]; +}) => + image ? ( + + ) : null; + const DraftBadge = () => { const t = useTranslations("core.search"); @@ -144,24 +238,37 @@ const TimelineItem = ({ const renderer = getSearchTypeRenderer(item.itemType); const Icon = renderer.icon; const snippet = getSnippet(item.content); + const authors = authorsOf(item); const content = ( <> -
- {item.author && ( - <> - - - {item.author.name} - - · - - )} - - {!item.isPublic && } +
+
+ {authors.length > 0 && ( + <> + + + + + + · + + + )} + + {!item.isPublic && !item.isHidden && } +
+

+ + {item.title} +

+ {snippet &&

{snippet}

}
-

{item.title}

- {snippet &&

{snippet}

} + ); @@ -179,13 +286,13 @@ const TimelineItem = ({
{item.url ? ( {content} ) : ( -
+
{content}
)} @@ -199,43 +306,46 @@ const SearchResultCard = ({ item }: { item: SearchResultItem }) => { const renderer = getSearchTypeRenderer(item.itemType); const Icon = renderer.icon; const snippet = getSnippet(item.content); + const authors = authorsOf(item); return ( - - {item.author ? ( - - ) : ( - - - - )} - -
-
- - - {t(renderer.labelKey)} + +
+ {item.author ? ( + + ) : ( + + - {item.author && ( - - {item.author.name} + )} + +
+
+ + + {t(renderer.labelKey)} + {authors.length > 0 && } + + {!item.isPublic && !item.isHidden && } +
+ +

+ + + + +

+ + {snippet && ( +

+ {snippet} +

)} - - {!item.isPublic && }
- -

- -

- - {snippet && ( -

- {snippet} -

- )}
+
); diff --git a/packages/vitnode/src/views/search/types.ts b/packages/vitnode/src/views/search/types.ts index 341467f167..b571686f07 100644 --- a/packages/vitnode/src/views/search/types.ts +++ b/packages/vitnode/src/views/search/types.ts @@ -1,17 +1,22 @@ +export interface SearchResultAuthor { + avatarColor: string; + avatarUrl: null | string; + id: number; + name: string; + nameCode: string; +} + export interface SearchResultItem { - author: null | { - avatarColor: string; - avatarUrl: null | string; - id: number; - name: string; - nameCode: string; - }; + author: null | SearchResultAuthor; authorId: null | number; + authors?: SearchResultAuthor[]; containerId: null | number; containerType: null | string; content: string; createdAt: Date | string; id: number; + image?: null | { height?: number; url: string; width?: number }; + isHidden?: boolean; isPublic: boolean; itemId: number | string; itemType: string; diff --git a/plugins/blog/src/content/post.ts b/plugins/blog/src/content/post.ts index 83775557d2..cf75bd4efa 100644 --- a/plugins/blog/src/content/post.ts +++ b/plugins/blog/src/content/post.ts @@ -117,6 +117,7 @@ export const blogPostContentType = defineContentType({ contentFields: ["title", "content"], pathTemplate: "/blog/{slug}", authorField: "authorId", + imageField: "coverImage", }, delivery: { diff --git a/plugins/blog/src/locales/en.json b/plugins/blog/src/locales/en.json index 509e7a86a8..04552565b5 100644 --- a/plugins/blog/src/locales/en.json +++ b/plugins/blog/src/locales/en.json @@ -175,7 +175,12 @@ } }, "post": { - "published": "Published" + "published": "Published", + "hidden": { + "label": "Article hidden", + "title": "This article is hidden", + "desc": "Only its authors and editors can see it. Everyone else gets a page not found, and it stays out of search and the sitemap." + } } }, "@vitnode/blog:posts": "Articles", diff --git a/plugins/blog/src/pages/post-page.tsx b/plugins/blog/src/pages/post-page.tsx index db1a529e6f..3f385379e9 100644 --- a/plugins/blog/src/pages/post-page.tsx +++ b/plugins/blog/src/pages/post-page.tsx @@ -1,5 +1,11 @@ import { DateFormat } from "@vitnode/core/components/date-format"; +import { HiddenIcon } from "@vitnode/core/components/hidden-icon"; import { RichTextContent } from "@vitnode/core/components/rich-text"; +import { + Alert, + AlertDescription, + AlertTitle, +} from "@vitnode/core/components/ui/alert"; import { zodContentFileDescriptor, zodRichTextDocument, @@ -15,6 +21,7 @@ import { } from "@vitnode/core/tanstack/content"; import { fetcher } from "@vitnode/core/tanstack/fetcher"; import { getIntlRuntime } from "@vitnode/core/tanstack/i18n"; +import { EyeOffIcon } from "lucide-react"; import { useTranslations } from "use-intl"; import { z } from "zod"; @@ -102,11 +109,24 @@ const BlogPostPage = ({

) : null} -

+

+ {metadata.hidden ? ( + + ) : null} {item.title}

+ {metadata.hidden ? ( + } variant="warning"> + {t("hidden.title")} + {t("hidden.desc")} + + ) : null} + {item.coverImage ? ( {
-
+
{header ? ( - + + + ) : null}

{header?.title ?? t("heading")}

-
- - -
- {translationLanguages.length > 0 ? ( - target ? ( - { - setTarget(null); - }} - sourceName={sourceName} - targetName={languageName(target)} - /> - ) : ( - - ) - ) : null} + {translationLanguages.length > 0 ? ( + target ? ( + { + setTarget(null); + }} + sourceName={sourceName} + targetName={languageName(target)} + /> + ) : ( + + ) + ) : null} + + {reviewAvailable ? ( - {reviewAvailable ? ( - - ) : null} - + ) : null} +
+ + + +
-
diff --git a/plugins/blog/src/views/admin/article/editor/fields.tsx b/plugins/blog/src/views/admin/article/editor/fields.tsx index 91fafd7828..0933e348bf 100644 --- a/plugins/blog/src/views/admin/article/editor/fields.tsx +++ b/plugins/blog/src/views/admin/article/editor/fields.tsx @@ -7,7 +7,10 @@ import { useMultiLangField, } from "@vitnode/core/components/form/fields/multi-lang"; import { FormControl, FormMessage } from "@vitnode/core/components/ui/form"; -import { useContentForm } from "@vitnode/core/content/admin-form"; +import { + ContentFormHiddenIcon, + useContentForm, +} from "@vitnode/core/content/admin-form"; import { cn } from "cn"; import { useTranslations } from "use-intl"; @@ -53,20 +56,23 @@ export const ArticleTitleField = ({ field }: ItemAutoFormComponentProps) => { {t("content.post.fields.title")} - -