@byline/db-postgres 2.1.3 → 2.2.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.
@@ -7,6 +7,7 @@
7
7
  */
8
8
  import { eq, relations, sql } from 'drizzle-orm';
9
9
  import { bigint, boolean, customType, date, decimal, index, integer, jsonb, pgTable, pgView, real, text, time, timestamp, unique, uuid, varchar, } from 'drizzle-orm/pg-core';
10
+ import { createdAt, timestamps } from './common.js';
10
11
  /**
11
12
  * `varchar(...)` with explicit byte-wise (C) collation.
12
13
  *
@@ -44,8 +45,7 @@ export const collections = pgTable('byline_collections', {
44
45
  // `ensureCollections()` run post-migration, tightens to NOT NULL when
45
46
  // the `collection_versions` history table lands.
46
47
  schema_hash: varchar('schema_hash', { length: 64 }),
47
- created_at: timestamp('created_at').defaultNow(),
48
- updated_at: timestamp('updated_at').defaultNow(),
48
+ ...timestamps,
49
49
  });
50
50
  // Documents table
51
51
  export const documents = pgTable('byline_documents', {
@@ -63,8 +63,7 @@ export const documents = pgTable('byline_documents', {
63
63
  // comparison — the fractional-index algorithm requires this. See
64
64
  // `varcharByteSorted` above and docs/COLLECTIONS.md (Orderable collections).
65
65
  order_key: varcharByteSorted('order_key', { length: 128 }),
66
- created_at: timestamp('created_at').defaultNow(),
67
- updated_at: timestamp('updated_at').defaultNow(),
66
+ ...timestamps,
68
67
  }, (table) => [
69
68
  index('idx_documents_collection').on(table.collection_id),
70
69
  index('idx_documents_collection_order').on(table.collection_id, table.order_key),
@@ -87,8 +86,7 @@ export const documentVersions = pgTable('byline_document_versions', {
87
86
  event_type: varchar('event_type', { length: 20 }).notNull().default('create'), // 'create', 'update', 'delete'
88
87
  status: varchar('status', { length: 50 }).default('draft'),
89
88
  is_deleted: boolean('is_deleted').default(false), // Tombstone for soft deletes
90
- created_at: timestamp('created_at').defaultNow(),
91
- updated_at: timestamp('updated_at').defaultNow(),
89
+ ...timestamps,
92
90
  created_by: uuid('created_by'),
93
91
  change_summary: text('change_summary'),
94
92
  }, (table) => [
@@ -121,8 +119,7 @@ export const documentPaths = pgTable('byline_document_paths', {
121
119
  .notNull()
122
120
  .references(() => collections.id, { onDelete: 'cascade' }),
123
121
  path: varchar('path', { length: 255 }).notNull(),
124
- created_at: timestamp('created_at').defaultNow(),
125
- updated_at: timestamp('updated_at').defaultNow(),
122
+ ...timestamps,
126
123
  }, (table) => [
127
124
  // One path per (logical document, locale).
128
125
  unique('unique_document_paths_document_locale').on(table.document_id, table.locale),
@@ -142,7 +139,7 @@ export const documentRelationships = pgTable('byline_document_relationships', {
142
139
  child_document_id: uuid('child_document_id')
143
140
  .notNull()
144
141
  .references(() => documents.id, { onDelete: 'cascade' }),
145
- created_at: timestamp('created_at').defaultNow(),
142
+ ...createdAt,
146
143
  }, (table) => [
147
144
  // Composite primary key to ensure a child is only parented once by the same parent.
148
145
  unique().on(table.parent_document_id, table.child_document_id),
@@ -258,8 +255,7 @@ const baseStoreColumns = {
258
255
  field_name: varchar('field_name', { length: 255 }).notNull(),
259
256
  locale: varchar('locale', { length: 10 }).notNull().default('default'),
260
257
  parent_path: varchar('parent_path', { length: 500 }),
261
- created_at: timestamp('created_at').defaultNow(),
262
- updated_at: timestamp('updated_at').defaultNow(),
258
+ ...timestamps,
263
259
  };
264
260
  // 1. TEXT FIELDS TABLE
265
261
  export const textStore = pgTable('byline_store_text', {
@@ -364,8 +360,7 @@ export const metaStore = pgTable('byline_store_meta', {
364
360
  // Optional opaque metadata payload for this node. Common attributes like
365
361
  // label, icon, collapsed state, etc. can be stored here.
366
362
  meta: jsonb('meta'),
367
- created_at: timestamp('created_at').defaultNow(),
368
- updated_at: timestamp('updated_at').defaultNow(),
363
+ ...timestamps,
369
364
  }, (table) => [
370
365
  // Fast lookup by document and node type/path when enriching reconstructed
371
366
  // trees with meta information.
@@ -431,6 +426,26 @@ export const jsonStore = pgTable('byline_store_json', {
431
426
  index('idx_json_keys').using('gin', table.object_keys),
432
427
  unique('unique_json_field').on(table.document_version_id, table.field_path, table.locale),
433
428
  ]);
429
+ // ---------------------------------------------------------------------------
430
+ // Counter groups registry
431
+ // ---------------------------------------------------------------------------
432
+ //
433
+ // One row per counter `group` discovered in collection field definitions.
434
+ // The actual ID allocator is a Postgres SEQUENCE (named in `sequence_name`),
435
+ // reconciled at boot by `IDbAdapter.ensureCounterGroup`. The registry table
436
+ // itself only records that the group exists and which sequence backs it —
437
+ // it is not used in the hot allocation path (`nextval()` operates on the
438
+ // sequence object directly).
439
+ //
440
+ // Why a separate table rather than reading sequences from
441
+ // `information_schema`: the mapping from `group_name` → `sequence_name`
442
+ // belongs in the application's schema, not in PG metadata, so backups and
443
+ // adapter logic have a stable name to anchor against.
444
+ export const counterGroups = pgTable('byline_counter_groups', {
445
+ group_name: text('group_name').primaryKey(),
446
+ sequence_name: text('sequence_name').notNull(),
447
+ ...createdAt,
448
+ });
434
449
  // RELATIONS
435
450
  // =========
436
451
  export const collectionsRelations = relations(collections, ({ many }) => ({
package/dist/index.js CHANGED
@@ -8,6 +8,7 @@
8
8
  import { drizzle } from 'drizzle-orm/node-postgres';
9
9
  import pg from 'pg';
10
10
  import * as schema from './database/schema/index.js';
11
+ import { createCounterCommands } from './modules/counters/counters-commands.js';
11
12
  import { createCommandBuilders } from './modules/storage/storage-commands.js';
12
13
  import { createQueryBuilders } from './modules/storage/storage-queries.js';
13
14
  export const pgAdapter = ({ connectionString, collections, defaultContentLocale, }) => {
@@ -20,8 +21,12 @@ export const pgAdapter = ({ connectionString, collections, defaultContentLocale,
20
21
  const db = drizzle(pool, { schema });
21
22
  const commandBuilders = createCommandBuilders(db, defaultContentLocale);
22
23
  const queryBuilders = createQueryBuilders(db, collections, defaultContentLocale);
24
+ const counterCommands = createCounterCommands(db);
23
25
  return {
24
- commands: commandBuilders,
26
+ commands: {
27
+ ...commandBuilders,
28
+ counters: counterCommands,
29
+ },
25
30
  queries: queryBuilders,
26
31
  drizzle: db,
27
32
  pool,
@@ -0,0 +1,22 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+ import type { ICounterCommands } from '@byline/core';
9
+ import type { NodePgDatabase } from 'drizzle-orm/node-postgres';
10
+ import type * as schema from '../../database/schema/index.js';
11
+ type DatabaseConnection = NodePgDatabase<typeof schema>;
12
+ export declare class CounterCommands implements ICounterCommands {
13
+ private db;
14
+ constructor(db: DatabaseConnection);
15
+ ensureCounterGroup(groupName: string): Promise<{
16
+ groupName: string;
17
+ sequenceName: string;
18
+ }>;
19
+ nextCounterValue(groupName: string): Promise<number>;
20
+ }
21
+ export declare function createCounterCommands(db: DatabaseConnection): ICounterCommands;
22
+ export {};
@@ -0,0 +1,96 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+ import { createHash } from 'node:crypto';
9
+ import { eq, sql } from 'drizzle-orm';
10
+ import { counterGroups } from '../../database/schema/index.js';
11
+ /**
12
+ * Derive a stable, safe Postgres identifier for the sequence backing a
13
+ * counter group.
14
+ *
15
+ * Two competing goals: humans browsing the database should be able to
16
+ * recognise which group a sequence belongs to, and the name must never
17
+ * collide for two different group strings. We satisfy both by combining
18
+ * a sanitised slug of the group name with an 8-character SHA-256 prefix
19
+ * — so `'library-facets'` becomes something like
20
+ * `byline_cseq_library_facets_8c2f4d6a`.
21
+ *
22
+ * Postgres identifier limit is 63 bytes (NAMEDATALEN-1). The prefix is
23
+ * 12 bytes, the hash + underscore is 9, leaving 42 bytes for the slug.
24
+ */
25
+ function deriveSequenceName(groupName) {
26
+ const slug = groupName
27
+ .toLowerCase()
28
+ .replace(/[^a-z0-9]+/g, '_')
29
+ .replace(/^_+|_+$/g, '')
30
+ .slice(0, 42);
31
+ const hash = createHash('sha256').update(groupName).digest('hex').slice(0, 8);
32
+ return slug ? `byline_cseq_${slug}_${hash}` : `byline_cseq_${hash}`;
33
+ }
34
+ export class CounterCommands {
35
+ db;
36
+ constructor(db) {
37
+ this.db = db;
38
+ }
39
+ async ensureCounterGroup(groupName) {
40
+ if (!groupName || typeof groupName !== 'string') {
41
+ throw new Error(`ensureCounterGroup: groupName must be a non-empty string`);
42
+ }
43
+ const sequenceName = deriveSequenceName(groupName);
44
+ // Two statements, both idempotent. We deliberately do not wrap them
45
+ // in a single transaction: CREATE SEQUENCE acquires its own catalog
46
+ // locks, and DDL inside a long-running INSERT can deadlock under
47
+ // concurrent boots. Splitting them keeps each step short.
48
+ //
49
+ // The sequence is created first so that even if the INSERT loses a
50
+ // race, the next caller's nextval() on the existing row's
51
+ // sequence_name still works.
52
+ await this.db.execute(sql.raw(`CREATE SEQUENCE IF NOT EXISTS "${sequenceName}" AS BIGINT`));
53
+ await this.db
54
+ .insert(counterGroups)
55
+ .values({ group_name: groupName, sequence_name: sequenceName })
56
+ .onConflictDoNothing({ target: counterGroups.group_name });
57
+ return { groupName, sequenceName };
58
+ }
59
+ async nextCounterValue(groupName) {
60
+ if (!groupName || typeof groupName !== 'string') {
61
+ throw new Error(`nextCounterValue: groupName must be a non-empty string`);
62
+ }
63
+ // Resolve the sequence name from the registry rather than re-deriving
64
+ // it. This keeps callers honest — if the group was never registered
65
+ // via ensureCounterGroup, we surface that immediately rather than
66
+ // silently creating a new sequence the first time a value is asked
67
+ // for (which would mask configuration bugs during boot).
68
+ const rows = await this.db
69
+ .select({ sequence_name: counterGroups.sequence_name })
70
+ .from(counterGroups)
71
+ .where(eq(counterGroups.group_name, groupName))
72
+ .limit(1);
73
+ if (rows.length === 0) {
74
+ throw new Error(`nextCounterValue: counter group "${groupName}" is not registered. ` +
75
+ `Call ensureCounterGroup at boot before any document create that uses it.`);
76
+ }
77
+ const sequenceName = rows[0].sequence_name;
78
+ // sql.raw is safe here: sequenceName came from our own registry row,
79
+ // which was written by ensureCounterGroup from deriveSequenceName
80
+ // — only [a-z0-9_] characters, no user-controlled SQL.
81
+ const result = await this.db.execute(sql.raw(`SELECT nextval('"${sequenceName}"') AS value`));
82
+ // Drizzle/node-postgres returns rows on result.rows; the value comes
83
+ // back as a string because pg's BIGINT default is string. Parse it —
84
+ // counter values that overflow Number.MAX_SAFE_INTEGER are not a
85
+ // realistic concern for the facet-URL use case (we'd run out of
86
+ // useful URL space long before).
87
+ const row = result.rows[0];
88
+ if (row === undefined) {
89
+ throw new Error(`nextCounterValue: nextval returned no row for group "${groupName}"`);
90
+ }
91
+ return typeof row.value === 'number' ? row.value : Number(row.value);
92
+ }
93
+ }
94
+ export function createCounterCommands(db) {
95
+ return new CounterCommands(db);
96
+ }
@@ -19,9 +19,9 @@ export declare class CollectionCommands implements ICollectionCommands {
19
19
  version?: number;
20
20
  schemaHash?: string;
21
21
  }): Promise<{
22
+ created_at: Date;
23
+ updated_at: Date;
22
24
  id: string;
23
- created_at: Date | null;
24
- updated_at: Date | null;
25
25
  config: unknown;
26
26
  path: string;
27
27
  singular: string;
@@ -34,6 +34,8 @@ export declare class CollectionCommands implements ICollectionCommands {
34
34
  version?: number;
35
35
  schemaHash?: string;
36
36
  }): Promise<{
37
+ created_at: Date;
38
+ updated_at: Date;
37
39
  id: string;
38
40
  path: string;
39
41
  singular: string;
@@ -41,8 +43,6 @@ export declare class CollectionCommands implements ICollectionCommands {
41
43
  config: unknown;
42
44
  version: number;
43
45
  schema_hash: string | null;
44
- created_at: Date | null;
45
- updated_at: Date | null;
46
46
  }[]>;
47
47
  delete(id: string): Promise<import("pg").QueryResult<never>>;
48
48
  }
@@ -82,9 +82,9 @@ export declare class DocumentCommands implements IDocumentCommands {
82
82
  orderKey?: string;
83
83
  }): Promise<{
84
84
  document: {
85
+ created_at: Date;
86
+ updated_at: Date;
85
87
  id: string;
86
- created_at: Date | null;
87
- updated_at: Date | null;
88
88
  collection_id: string;
89
89
  document_id: string;
90
90
  collection_version: number;
@@ -152,6 +152,7 @@ const flattenValueFieldData = (field, field_path, value, locale) => {
152
152
  value_float: value,
153
153
  };
154
154
  case 'integer':
155
+ case 'counter':
155
156
  return {
156
157
  locale,
157
158
  field_path,
@@ -16,6 +16,8 @@ export declare class CollectionQueries implements ICollectionQueries {
16
16
  private db;
17
17
  constructor(db: DatabaseConnection);
18
18
  getAllCollections(): Promise<{
19
+ created_at: Date;
20
+ updated_at: Date;
19
21
  id: string;
20
22
  path: string;
21
23
  singular: string;
@@ -23,13 +25,11 @@ export declare class CollectionQueries implements ICollectionQueries {
23
25
  config: unknown;
24
26
  version: number;
25
27
  schema_hash: string | null;
26
- created_at: Date | null;
27
- updated_at: Date | null;
28
28
  }[]>;
29
29
  getCollectionByPath(path: string): Promise<{
30
+ created_at: Date;
31
+ updated_at: Date;
30
32
  id: string;
31
- created_at: Date | null;
32
- updated_at: Date | null;
33
33
  config: unknown;
34
34
  path: string;
35
35
  singular: string;
@@ -38,9 +38,9 @@ export declare class CollectionQueries implements ICollectionQueries {
38
38
  schema_hash: string | null;
39
39
  } | undefined>;
40
40
  getCollectionById(id: string): Promise<{
41
+ created_at: Date;
42
+ updated_at: Date;
41
43
  id: string;
42
- created_at: Date | null;
43
- updated_at: Date | null;
44
44
  config: unknown;
45
45
  path: string;
46
46
  singular: string;
@@ -192,8 +192,8 @@ export declare class DocumentQueries implements IDocumentQueries {
192
192
  document_id: string;
193
193
  path: string;
194
194
  status: string | null;
195
- created_at: Date | null;
196
- updated_at: Date | null;
195
+ created_at: Date;
196
+ updated_at: Date;
197
197
  fields: any;
198
198
  } | null>;
199
199
  getDocumentByPath({ collection_id, path, locale, reconstruct, readMode, filters, }: {
@@ -208,8 +208,8 @@ export declare class DocumentQueries implements IDocumentQueries {
208
208
  document_id: string;
209
209
  path: string;
210
210
  status: string | null;
211
- created_at: Date | null;
212
- updated_at: Date | null;
211
+ created_at: Date;
212
+ updated_at: Date;
213
213
  fields: any;
214
214
  } | null>;
215
215
  /**
@@ -1074,11 +1074,22 @@ export class DocumentQueries {
1074
1074
  return sql `${column} ILIKE ${`%${String(value)}%`}`;
1075
1075
  case '$in': {
1076
1076
  const arr = value;
1077
- return sql `${column} = ANY(${arr})`;
1077
+ // Empty `$in` matches nothing — explicit FALSE avoids generating
1078
+ // an invalid empty `IN ()` clause.
1079
+ if (arr.length === 0)
1080
+ return sql `FALSE`;
1081
+ // Bind each element as its own parameter. Drizzle's `${arr}` would
1082
+ // serialise as a single row-constructor (`($1, $2)`), which Postgres
1083
+ // rejects when compared to a scalar column with `= ANY(...)`.
1084
+ const items = sql.join(arr.map((v) => sql `${v}`), sql `, `);
1085
+ return sql `${column} IN (${items})`;
1078
1086
  }
1079
1087
  case '$nin': {
1080
1088
  const arr = value;
1081
- return sql `${column} != ALL(${arr})`;
1089
+ if (arr.length === 0)
1090
+ return sql `TRUE`;
1091
+ const items = sql.join(arr.map((v) => sql `${v}`), sql `, `);
1092
+ return sql `${column} NOT IN (${items})`;
1082
1093
  }
1083
1094
  default:
1084
1095
  throw ERR_DATABASE({
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@byline/db-postgres",
3
3
  "private": false,
4
4
  "license": "MPL-2.0",
5
- "version": "2.1.3",
5
+ "version": "2.2.0",
6
6
  "engines": {
7
7
  "node": ">=20.9.0"
8
8
  },
@@ -57,9 +57,9 @@
57
57
  "pg": "^8.20.0",
58
58
  "uuid": "^14.0.0",
59
59
  "zod": "^4.4.3",
60
- "@byline/admin": "2.1.3",
61
- "@byline/auth": "2.1.3",
62
- "@byline/core": "2.1.3"
60
+ "@byline/admin": "2.2.0",
61
+ "@byline/auth": "2.2.0",
62
+ "@byline/core": "2.2.0"
63
63
  },
64
64
  "devDependencies": {
65
65
  "@biomejs/biome": "2.4.15",
@@ -1,8 +0,0 @@
1
- /**
2
- * This Source Code is subject to the terms of the Mozilla Public
3
- * License, v. 2.0. If a copy of the MPL was not distributed with this
4
- * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
- *
6
- * Copyright (c) Infonomic Company Limited
7
- */
8
- export {};
@@ -1,27 +0,0 @@
1
- /**
2
- * This Source Code is subject to the terms of the Mozilla Public
3
- * License, v. 2.0. If a copy of the MPL was not distributed with this
4
- * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
- *
6
- * Copyright (c) Infonomic Company Limited
7
- */
8
- /**
9
- * Per-test-file bootstrap for node:test integration tests.
10
- *
11
- * The `tsx --env-file=.env.test --import ./src/lib/test-bootstrap.ts --test`
12
- * invocation runs this module once at the start of each test-file process,
13
- * before any test imports resolve. It:
14
- *
15
- * 1. Asserts `POSTGRES_CONNECTION_STRING` targets a `_test` database
16
- * (belt; the script-level guard in `common.sh` is the braces).
17
- * 2. Applies Drizzle migrations (idempotent — cheap on re-run).
18
- * 3. Truncates all `public` tables so the file starts from a known state.
19
- *
20
- * Top-level await ensures the file's own `before()` hooks don't run until
21
- * the database is ready.
22
- */
23
- import { assertTestDatabase, migrateTestDatabase, resetTestDatabase } from './test-db.js';
24
- const connectionString = process.env.POSTGRES_CONNECTION_STRING;
25
- assertTestDatabase(connectionString);
26
- await migrateTestDatabase(connectionString);
27
- await resetTestDatabase(connectionString);