@manablox/db 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,41 @@
1
+ {
2
+ "version": "7",
3
+ "dialect": "postgresql",
4
+ "entries": [
5
+ {
6
+ "idx": 0,
7
+ "version": "7",
8
+ "when": 1788506703488,
9
+ "tag": "0000_init",
10
+ "breakpoints": true
11
+ },
12
+ {
13
+ "idx": 1,
14
+ "version": "7",
15
+ "when": 1788506841571,
16
+ "tag": "0001_permalink-path",
17
+ "breakpoints": true
18
+ },
19
+ {
20
+ "idx": 2,
21
+ "version": "7",
22
+ "when": 1788508162118,
23
+ "tag": "0002_account-issuer",
24
+ "breakpoints": true
25
+ },
26
+ {
27
+ "idx": 3,
28
+ "version": "7",
29
+ "when": 1788514181873,
30
+ "tag": "0003_asset-usages",
31
+ "breakpoints": true
32
+ },
33
+ {
34
+ "idx": 4,
35
+ "version": "7",
36
+ "when": 1788544039067,
37
+ "tag": "0004_apikey-spaces",
38
+ "breakpoints": true
39
+ }
40
+ ]
41
+ }
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "@manablox/db",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "exports": {
6
+ ".": {
7
+ "types": "./src/index.ts",
8
+ "default": "./src/index.ts"
9
+ },
10
+ "./schema": {
11
+ "types": "./src/schema.ts",
12
+ "default": "./src/schema.ts"
13
+ }
14
+ },
15
+ "main": "./src/index.ts",
16
+ "types": "./src/index.ts",
17
+ "dependencies": {
18
+ "@manablox/core": "0.1.0",
19
+ "drizzle-orm": "^0.45.2",
20
+ "postgres": "^3.4.9"
21
+ },
22
+ "devDependencies": {
23
+ "@manablox/config-typescript": "0.0.0",
24
+ "@types/node": "^26.4.1",
25
+ "drizzle-kit": "^0.31.10",
26
+ "testcontainers": "^12.1.0",
27
+ "tsx": "^4.20.7",
28
+ "typescript": "^7.0.2",
29
+ "vitest": "^5.0.0"
30
+ },
31
+ "scripts": {
32
+ "generate": "drizzle-kit generate",
33
+ "migrate": "tsx src/cli/migrate.ts",
34
+ "typecheck": "tsc --noEmit",
35
+ "test": "vitest run"
36
+ }
37
+ }
@@ -0,0 +1,13 @@
1
+ import type { Sql } from './client.js';
2
+
3
+ /**
4
+ * Extensions the schema depends on. Run before the generated migrations, which
5
+ * reference `ltree` columns and `pg_trgm` operator classes.
6
+ */
7
+ export async function applyBootstrapSql(sql: Sql): Promise<void> {
8
+ await sql.unsafe(`
9
+ create extension if not exists "ltree";
10
+ create extension if not exists "pg_trgm";
11
+ create extension if not exists "btree_gin";
12
+ `);
13
+ }
@@ -0,0 +1,24 @@
1
+ import { dirname, resolve } from 'node:path';
2
+ import { fileURLToPath } from 'node:url';
3
+ import { migrate } from 'drizzle-orm/postgres-js/migrator';
4
+ import { applyBootstrapSql } from '../bootstrap.js';
5
+ import { createDatabase } from '../client.js';
6
+
7
+ const migrationsFolder = resolve(dirname(fileURLToPath(import.meta.url)), '../../migrations');
8
+
9
+ const url = process.env.DATABASE_URL;
10
+ if (!url) {
11
+ console.error('DATABASE_URL is required');
12
+ process.exit(1);
13
+ }
14
+
15
+ const handle = createDatabase({ url, max: 1 });
16
+
17
+ try {
18
+ // Extensions must exist before the generated migrations reference ltree columns.
19
+ await applyBootstrapSql(handle.sql);
20
+ await migrate(handle.db, { migrationsFolder });
21
+ console.info('migrations applied');
22
+ } finally {
23
+ await handle.close();
24
+ }
package/src/client.ts ADDED
@@ -0,0 +1,37 @@
1
+ import type { DatabaseConfig } from '@manablox/core';
2
+ import { drizzle, type PostgresJsDatabase } from 'drizzle-orm/postgres-js';
3
+ import postgres from 'postgres';
4
+ import * as schema from './schema.js';
5
+
6
+ export type Database = PostgresJsDatabase<typeof schema>;
7
+ export type Sql = ReturnType<typeof postgres>;
8
+
9
+ export interface DatabaseHandle {
10
+ db: Database;
11
+ sql: Sql;
12
+ close: () => Promise<void>;
13
+ }
14
+
15
+ export interface DatabaseOptions {
16
+ /** Called once per statement sent to Postgres. Used by tests to assert query counts. */
17
+ onQuery?: (query: string) => void;
18
+ }
19
+
20
+ export function createDatabase(
21
+ config: DatabaseConfig,
22
+ options: DatabaseOptions = {},
23
+ ): DatabaseHandle {
24
+ const sql = postgres(config.url, {
25
+ max: config.max ?? 10,
26
+ ...(options.onQuery ? { debug: (_c: number, query: string) => options.onQuery?.(query) } : {}),
27
+ ...(config.ssl ? { ssl: 'require' as const } : {}),
28
+ // `ltree` and `tsvector` have no client-side parser, so postgres.js returns them as
29
+ // strings — which is exactly what the schema declares. Do not pass `types: {}` here:
30
+ // it replaces the built-in serialisers wholesale and Date parameters stop working.
31
+ onnotice: () => {},
32
+ });
33
+
34
+ const db = drizzle(sql, { schema, casing: 'snake_case' });
35
+
36
+ return { db, sql, close: () => sql.end({ timeout: 5 }) };
37
+ }
package/src/columns.ts ADDED
@@ -0,0 +1,39 @@
1
+ import { customType } from 'drizzle-orm/pg-core';
2
+
3
+ /**
4
+ * `ltree` — the materialised ancestor path of a content node. A subtree move is one
5
+ * `UPDATE ... SET path = :newParent || subpath(path, nlevel(:oldParent))`.
6
+ */
7
+ export const ltree = customType<{ data: string; driverData: string }>({
8
+ dataType: () => 'ltree',
9
+ });
10
+
11
+ /** `tsvector` — generated from `title` + `search_text`, never written directly. */
12
+ export const tsvector = customType<{ data: string; driverData: string }>({
13
+ dataType: () => 'tsvector',
14
+ });
15
+
16
+ /**
17
+ * ltree labels accept only `[A-Za-z0-9_]`, so UUID hyphens are swapped for underscores.
18
+ * The transform is total and reversible.
19
+ */
20
+ export const idToLabel = (id: string): string => id.replaceAll('-', '_');
21
+ export const labelToId = (label: string): string => {
22
+ const hex = label.replaceAll('_', '');
23
+ return [
24
+ hex.slice(0, 8),
25
+ hex.slice(8, 12),
26
+ hex.slice(12, 16),
27
+ hex.slice(16, 20),
28
+ hex.slice(20, 32),
29
+ ].join('-');
30
+ };
31
+
32
+ /** Builds the path of a node from its ancestors' ids (root first) plus its own. */
33
+ export const buildPath = (ancestorIds: string[], selfId: string): string =>
34
+ [...ancestorIds, selfId].map(idToLabel).join('.');
35
+
36
+ /** Splits a stored path back into ids, root first, self last. */
37
+ export const parsePath = (path: string): string[] => path.split('.').filter(Boolean).map(labelToId);
38
+
39
+ export const pathDepth = (path: string): number => (path ? path.split('.').length : 0);
package/src/index.ts ADDED
@@ -0,0 +1,13 @@
1
+ export * from './bootstrap.js';
2
+ export * from './client.js';
3
+ export * from './columns.js';
4
+ export * from './query.js';
5
+ export * from './repositories/asset.js';
6
+ export * from './repositories/asset-usage.js';
7
+ export * from './repositories/content.js';
8
+ export * from './repositories/content-type.js';
9
+ export * from './repositories/index.js';
10
+ export * from './repositories/space.js';
11
+ export * from './repositories/user.js';
12
+ export * as schema from './schema.js';
13
+ export * from './schema.js';
package/src/query.ts ADDED
@@ -0,0 +1,217 @@
1
+ import { type ContentTypeRegistry, type FilterOperator, ManabloxError } from '@manablox/core';
2
+ import { and, eq, inArray, isNull, type SQL, sql } from 'drizzle-orm';
3
+ import type { PgColumn } from 'drizzle-orm/pg-core';
4
+ import type { contents, publishedContents } from './schema.js';
5
+
6
+ /**
7
+ * Both content tables share a column set, so one filter builder serves the draft and
8
+ * the published projection. Typing the parameter as the union (rather than `PgTable`)
9
+ * keeps real column references, which is what lets Drizzle bind arrays and UUIDs
10
+ * correctly instead of stringifying them into the SQL text.
11
+ */
12
+ export type ContentTable = typeof contents | typeof publishedContents;
13
+
14
+ /**
15
+ * Note on `?: T | undefined` throughout the input types in this file: the workspace runs
16
+ * with `exactOptionalPropertyTypes`, under which a bare `?:` accepts an absent property
17
+ * but rejects an explicit `undefined`. Validators (Zod, and any Standard Schema) produce
18
+ * exactly that explicit `undefined` for optional fields, so input DTOs widen while the
19
+ * domain types they feed stay strict.
20
+ */
21
+ export interface FieldFilter {
22
+ /** Field machine name on the content type. */
23
+ name: string;
24
+ op: FilterOperator;
25
+ value?: unknown | undefined;
26
+ }
27
+
28
+ export interface ContentFilter {
29
+ spaceId?: string | undefined;
30
+ /** Content type ids. */
31
+ typeIds?: string[] | undefined;
32
+ locale?: string | undefined;
33
+ status?: 'draft' | 'published' | 'archived' | undefined;
34
+ ids?: string[] | undefined;
35
+ parentId?: string | null | undefined;
36
+ /** Restrict to the subtree below this content id (inclusive of its descendants). */
37
+ under?: string | undefined;
38
+ slug?: string | undefined;
39
+ permalink?: string | undefined;
40
+ localizationId?: string | undefined;
41
+ visibleInMenu?: boolean | undefined;
42
+ /** Full-text query against `title` + field contributions. */
43
+ search?: string | undefined;
44
+ fields?: FieldFilter[] | undefined;
45
+ }
46
+
47
+ export interface ContentSort {
48
+ by: 'position' | 'title' | 'createdAt' | 'updatedAt' | 'publishedAt' | 'slug';
49
+ direction: 'asc' | 'desc';
50
+ }
51
+
52
+ export interface Pagination {
53
+ limit: number;
54
+ offset: number;
55
+ }
56
+
57
+ const SORT_COLUMNS: Record<ContentSort['by'], string> = {
58
+ position: 'position',
59
+ title: 'title',
60
+ createdAt: 'created_at',
61
+ updatedAt: 'updated_at',
62
+ publishedAt: 'published_at',
63
+ slug: 'slug',
64
+ };
65
+
66
+ /**
67
+ * Translates a filter into SQL. Every field predicate is checked against the field
68
+ * type's declared `filters` list first: an unsupported operator is a 400, and every
69
+ * supported one has an index behind it.
70
+ */
71
+ export function buildContentWhere(
72
+ table: ContentTable,
73
+ filter: ContentFilter,
74
+ registry: ContentTypeRegistry,
75
+ ): SQL | undefined {
76
+ const conditions: SQL[] = [];
77
+
78
+ if (filter.spaceId) conditions.push(eq(table.spaceId, filter.spaceId));
79
+ if (filter.locale) conditions.push(eq(table.locale, filter.locale));
80
+ if (filter.status) conditions.push(eq(table.status, filter.status));
81
+ if (filter.localizationId) conditions.push(eq(table.localizationId, filter.localizationId));
82
+ if (filter.slug) conditions.push(eq(table.slug, filter.slug));
83
+ if (filter.permalink !== undefined) conditions.push(eq(table.permalink, filter.permalink));
84
+ if (filter.visibleInMenu !== undefined) {
85
+ conditions.push(eq(table.visibleInMenu, filter.visibleInMenu));
86
+ }
87
+ if (filter.typeIds?.length) conditions.push(inArray(table.typeId, filter.typeIds));
88
+ if (filter.ids?.length) conditions.push(inArray(table.id, filter.ids));
89
+
90
+ if (filter.parentId !== undefined) {
91
+ conditions.push(
92
+ filter.parentId === null ? isNull(table.parentId) : eq(table.parentId, filter.parentId),
93
+ );
94
+ }
95
+
96
+ if (filter.under) {
97
+ // GiST-indexed ancestor test — the whole subtree without a recursive query.
98
+ conditions.push(
99
+ sql`${table.path} <@ (select path from ${table} where id = ${filter.under}::uuid)`,
100
+ );
101
+ }
102
+
103
+ if (filter.search) {
104
+ conditions.push(sql`${table.search} @@ websearch_to_tsquery('simple', ${filter.search})`);
105
+ }
106
+
107
+ for (const fieldFilter of filter.fields ?? []) {
108
+ conditions.push(buildFieldCondition(table, fieldFilter, filter.typeIds ?? [], registry));
109
+ }
110
+
111
+ return conditions.length > 0 ? and(...conditions) : undefined;
112
+ }
113
+
114
+ function buildFieldCondition(
115
+ table: ContentTable,
116
+ filter: FieldFilter,
117
+ typeIds: string[],
118
+ registry: ContentTypeRegistry,
119
+ ): SQL {
120
+ assertOperatorAllowed(filter, typeIds, registry);
121
+
122
+ const path = sql`${table.fields} -> ${filter.name}`;
123
+ const text = sql`${table.fields} ->> ${filter.name}`;
124
+
125
+ switch (filter.op) {
126
+ case 'eq':
127
+ // jsonb containment, so the `jsonb_path_ops` GIN index can serve it.
128
+ return sql`${table.fields} @> jsonb_build_object(${filter.name}::text, ${JSON.stringify(filter.value ?? null)}::jsonb)`;
129
+ case 'neq':
130
+ return sql`not (${table.fields} @> jsonb_build_object(${filter.name}::text, ${JSON.stringify(filter.value ?? null)}::jsonb))`;
131
+ case 'in':
132
+ return sql`${text} = any(${sql.param(asStringArray(filter.value))}::text[])`;
133
+ case 'notIn':
134
+ return sql`${text} <> all(${sql.param(asStringArray(filter.value))}::text[])`;
135
+ case 'lt':
136
+ return sql`(${text})::numeric < ${asNumber(filter.value)}`;
137
+ case 'lte':
138
+ return sql`(${text})::numeric <= ${asNumber(filter.value)}`;
139
+ case 'gt':
140
+ return sql`(${text})::numeric > ${asNumber(filter.value)}`;
141
+ case 'gte':
142
+ return sql`(${text})::numeric >= ${asNumber(filter.value)}`;
143
+ case 'contains':
144
+ return sql`${text} ilike ${`%${escapeLike(String(filter.value ?? ''))}%`}`;
145
+ case 'startsWith':
146
+ return sql`${text} ilike ${`${escapeLike(String(filter.value ?? ''))}%`}`;
147
+ case 'endsWith':
148
+ return sql`${text} ilike ${`%${escapeLike(String(filter.value ?? ''))}`}`;
149
+ case 'isNull':
150
+ return sql`(${path} is null or ${path} = 'null'::jsonb)`;
151
+ case 'isNotNull':
152
+ return sql`(${path} is not null and ${path} <> 'null'::jsonb)`;
153
+ default: {
154
+ const exhaustive: never = filter.op;
155
+ throw ManabloxError.badRequest('query.operator.unsupported', { op: exhaustive });
156
+ }
157
+ }
158
+ }
159
+
160
+ /**
161
+ * A field predicate is only accepted when *every* candidate content type declares a
162
+ * field of that name whose field type supports the operator.
163
+ */
164
+ function assertOperatorAllowed(
165
+ filter: FieldFilter,
166
+ typeIds: string[],
167
+ registry: ContentTypeRegistry,
168
+ ): void {
169
+ const candidates =
170
+ typeIds.length > 0 ? typeIds.map((id) => registry.get(id)) : registry.contentTypes;
171
+
172
+ const matching = candidates
173
+ .map((type) => type.fields.find((field) => field.name === filter.name))
174
+ .filter((field): field is NonNullable<typeof field> => field !== undefined);
175
+
176
+ if (matching.length === 0) {
177
+ throw ManabloxError.badRequest('query.field.unknown', { field: filter.name });
178
+ }
179
+
180
+ for (const field of matching) {
181
+ const fieldType = registry.fieldTypes.tryGet(field.type);
182
+ if (!fieldType?.filters.includes(filter.op)) {
183
+ throw ManabloxError.badRequest('query.operator.unsupported', {
184
+ field: filter.name,
185
+ fieldType: field.type,
186
+ op: filter.op,
187
+ });
188
+ }
189
+ }
190
+ }
191
+
192
+ export function buildOrderBy(sorts: ContentSort[]): SQL {
193
+ if (sorts.length === 0) {
194
+ return sql`${sql.identifier('position')} asc, ${sql.identifier('created_at')} asc`;
195
+ }
196
+ const parts = sorts.map((sort) => {
197
+ const column = SORT_COLUMNS[sort.by];
198
+ if (!column) throw ManabloxError.badRequest('query.sort.unsupported', { by: sort.by });
199
+ return sql`${sql.identifier(column)} ${sql.raw(sort.direction === 'desc' ? 'desc' : 'asc')}`;
200
+ });
201
+ return parts.reduce((acc, part) => sql`${acc}, ${part}`);
202
+ }
203
+
204
+ const escapeLike = (value: string): string => value.replace(/[%_\\]/g, (c) => `\\${c}`);
205
+
206
+ function asStringArray(value: unknown): string[] {
207
+ if (!Array.isArray(value)) throw ManabloxError.badRequest('query.value.expectedArray');
208
+ return value.map((entry) => String(entry));
209
+ }
210
+
211
+ function asNumber(value: unknown): number {
212
+ const parsed = Number(value);
213
+ if (Number.isNaN(parsed)) throw ManabloxError.badRequest('query.value.expectedNumber');
214
+ return parsed;
215
+ }
216
+
217
+ export type { PgColumn };
@@ -0,0 +1,166 @@
1
+ import { and, eq, inArray, notInArray, sql } from 'drizzle-orm';
2
+ import type { Database } from '../client.js';
3
+ import { assetUsages, contents, publishedContents } from '../schema.js';
4
+
5
+ /**
6
+ * The asset → document reachability index.
7
+ *
8
+ * `published` tracks the *published projection*, not the draft: a draft that adds an
9
+ * image does not make that image public, and a draft that removes one does not make it
10
+ * private until the change is published. Every method below preserves that distinction,
11
+ * which is why the column exists rather than the table simply holding published rows.
12
+ */
13
+ export class AssetUsageRepository {
14
+ constructor(private readonly db: Database) {}
15
+
16
+ /** The subset of `assetIds` reachable from at least one published document. */
17
+ async filterPublished(assetIds: string[]): Promise<Set<string>> {
18
+ if (assetIds.length === 0) return new Set();
19
+ const rows = await this.db
20
+ .selectDistinct({ assetId: assetUsages.assetId })
21
+ .from(assetUsages)
22
+ .where(and(inArray(assetUsages.assetId, assetIds), eq(assetUsages.published, true)));
23
+ return new Set(rows.map((row) => row.assetId));
24
+ }
25
+
26
+ async forContent(contentId: string): Promise<Array<{ assetId: string; published: boolean }>> {
27
+ return this.db
28
+ .select({ assetId: assetUsages.assetId, published: assetUsages.published })
29
+ .from(assetUsages)
30
+ .where(eq(assetUsages.contentId, contentId));
31
+ }
32
+
33
+ /**
34
+ * Records what a *draft* references.
35
+ *
36
+ * Rows the draft dropped are removed only if they are not currently published —
37
+ * otherwise editing a draft would silently revoke access to an image the live page is
38
+ * still showing.
39
+ */
40
+ async recordDraft(contentId: string, spaceId: string, assetIds: string[]): Promise<void> {
41
+ const unique = [...new Set(assetIds)];
42
+
43
+ await this.db.transaction(async (tx) => {
44
+ await tx
45
+ .delete(assetUsages)
46
+ .where(
47
+ and(
48
+ eq(assetUsages.contentId, contentId),
49
+ eq(assetUsages.published, false),
50
+ ...(unique.length > 0 ? [notInArray(assetUsages.assetId, unique)] : []),
51
+ ),
52
+ );
53
+
54
+ if (unique.length === 0) return;
55
+
56
+ await tx
57
+ .insert(assetUsages)
58
+ .values(
59
+ unique.map((assetId) => ({
60
+ assetId,
61
+ contentId,
62
+ spaceId,
63
+ published: false,
64
+ updatedAt: new Date(),
65
+ })),
66
+ )
67
+ // A row that is already published stays published: this write describes the
68
+ // draft, and `onConflictDoNothing` is the difference between recording a draft
69
+ // and revoking a live asset.
70
+ .onConflictDoNothing();
71
+ });
72
+ }
73
+
74
+ /**
75
+ * Records what the published projection references.
76
+ *
77
+ * Assets the new revision no longer uses lose their published flag but keep their row
78
+ * when the draft still references them, so the admin's "where is this used" view stays
79
+ * complete.
80
+ */
81
+ async recordPublished(contentId: string, spaceId: string, assetIds: string[]): Promise<void> {
82
+ const unique = [...new Set(assetIds)];
83
+
84
+ await this.db.transaction(async (tx) => {
85
+ await tx
86
+ .update(assetUsages)
87
+ .set({ published: false, updatedAt: new Date() })
88
+ .where(
89
+ and(
90
+ eq(assetUsages.contentId, contentId),
91
+ ...(unique.length > 0 ? [notInArray(assetUsages.assetId, unique)] : []),
92
+ ),
93
+ );
94
+
95
+ if (unique.length === 0) return;
96
+
97
+ await tx
98
+ .insert(assetUsages)
99
+ .values(
100
+ unique.map((assetId) => ({
101
+ assetId,
102
+ contentId,
103
+ spaceId,
104
+ published: true,
105
+ updatedAt: new Date(),
106
+ })),
107
+ )
108
+ .onConflictDoUpdate({
109
+ target: [assetUsages.assetId, assetUsages.contentId],
110
+ set: { published: true, updatedAt: new Date() },
111
+ });
112
+ });
113
+ }
114
+
115
+ /** Unpublishing revokes every asset this document was keeping public. */
116
+ async clearPublished(contentId: string): Promise<void> {
117
+ await this.db
118
+ .update(assetUsages)
119
+ .set({ published: false, updatedAt: new Date() })
120
+ .where(eq(assetUsages.contentId, contentId));
121
+ }
122
+
123
+ async deleteForContent(contentId: string): Promise<void> {
124
+ await this.db.delete(assetUsages).where(eq(assetUsages.contentId, contentId));
125
+ }
126
+
127
+ async count(): Promise<number> {
128
+ const rows = await this.db.select({ count: sql<number>`count(*)::int` }).from(assetUsages);
129
+ return rows[0]?.count ?? 0;
130
+ }
131
+
132
+ /**
133
+ * Every document with its draft and published field values, for the backfill.
134
+ *
135
+ * References are derived from field-type definitions rather than stored, so the
136
+ * backfill cannot be a SQL migration — it has to run inside the application.
137
+ */
138
+ async backfillSource(): Promise<
139
+ Array<{
140
+ id: string;
141
+ spaceId: string;
142
+ typeId: string;
143
+ draftFields: Record<string, unknown>;
144
+ publishedFields: Record<string, unknown> | null;
145
+ }>
146
+ > {
147
+ const rows = await this.db
148
+ .select({
149
+ id: contents.id,
150
+ spaceId: contents.spaceId,
151
+ typeId: contents.typeId,
152
+ draftFields: contents.fields,
153
+ publishedFields: publishedContents.fields,
154
+ })
155
+ .from(contents)
156
+ .leftJoin(publishedContents, eq(publishedContents.id, contents.id));
157
+
158
+ return rows.map((row) => ({
159
+ id: row.id,
160
+ spaceId: row.spaceId,
161
+ typeId: row.typeId,
162
+ draftFields: row.draftFields as Record<string, unknown>,
163
+ publishedFields: (row.publishedFields as Record<string, unknown> | null) ?? null,
164
+ }));
165
+ }
166
+ }