@byline/db-postgres 3.18.0 → 3.20.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.
@@ -17,6 +17,7 @@ export declare class CounterCommands implements ICounterCommands {
17
17
  sequenceName: string;
18
18
  }>;
19
19
  nextCounterValue(groupName: string): Promise<number>;
20
+ nextScopedCounterValue(scopeName: string): Promise<number>;
20
21
  }
21
22
  export declare function createCounterCommands(db: DatabaseConnection): ICounterCommands;
22
23
  export {};
@@ -90,6 +90,25 @@ export class CounterCommands {
90
90
  }
91
91
  return typeof row.value === 'number' ? row.value : Number(row.value);
92
92
  }
93
+ async nextScopedCounterValue(scopeName) {
94
+ if (!scopeName || typeof scopeName !== 'string') {
95
+ throw new Error(`nextScopedCounterValue: scopeName must be a non-empty string`);
96
+ }
97
+ // Fast path: the scope has been used before — its registry row and
98
+ // sequence already exist, so this is exactly a nextCounterValue call.
99
+ const rows = await this.db
100
+ .select({ sequence_name: counterGroups.sequence_name })
101
+ .from(counterGroups)
102
+ .where(eq(counterGroups.group_name, scopeName))
103
+ .limit(1);
104
+ if (rows.length === 0) {
105
+ // First allocation for this scope: self-register (idempotent — a
106
+ // concurrent racer leaves exactly one sequence + registry row, and
107
+ // both callers draw distinct values from the same sequence).
108
+ await this.ensureCounterGroup(scopeName);
109
+ }
110
+ return this.nextCounterValue(scopeName);
111
+ }
93
112
  }
94
113
  export function createCounterCommands(db) {
95
114
  return new CounterCommands(db);
@@ -0,0 +1,8 @@
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 {};
@@ -0,0 +1,100 @@
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 { eq, sql } from 'drizzle-orm';
9
+ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
10
+ import { counterGroups } from '../../../database/schema/index.js';
11
+ import { setupTestDB, teardownTestDB } from '../../../lib/test-helper.js';
12
+ import { createCounterCommands } from '../counters-commands.js';
13
+ // ---------------------------------------------------------------------------
14
+ // Track-and-clean fixtures
15
+ // ---------------------------------------------------------------------------
16
+ //
17
+ // Integration tests share the dev database. Each scope this suite touches
18
+ // creates a registry row + a Postgres sequence; track the scope names and
19
+ // drop both on teardown so repeated runs start from a clean slate (and so
20
+ // monotonicity assertions are not satisfied by a previous run's sequence).
21
+ let db;
22
+ let counters;
23
+ const trackedScopes = new Set();
24
+ async function cleanupScope(scopeName) {
25
+ const rows = await db
26
+ .select({ sequence_name: counterGroups.sequence_name })
27
+ .from(counterGroups)
28
+ .where(eq(counterGroups.group_name, scopeName))
29
+ .limit(1);
30
+ const sequenceName = rows[0]?.sequence_name;
31
+ await db.delete(counterGroups).where(eq(counterGroups.group_name, scopeName));
32
+ if (sequenceName) {
33
+ await db.execute(sql.raw(`DROP SEQUENCE IF EXISTS "${sequenceName}"`));
34
+ }
35
+ }
36
+ function scope(name) {
37
+ trackedScopes.add(name);
38
+ return name;
39
+ }
40
+ beforeAll(async () => {
41
+ const setup = setupTestDB();
42
+ db = setup.db;
43
+ counters = createCounterCommands(db);
44
+ // Clean any leftovers from a previously aborted run.
45
+ for (const name of trackedScopes) {
46
+ await cleanupScope(name);
47
+ }
48
+ });
49
+ afterAll(async () => {
50
+ for (const name of trackedScopes) {
51
+ await cleanupScope(name);
52
+ }
53
+ await teardownTestDB();
54
+ });
55
+ describe('nextScopedCounterValue', () => {
56
+ it('self-registers an unknown scope and allocates from 1', async () => {
57
+ const scopeName = scope('test:doc-a:files');
58
+ const first = await counters.nextScopedCounterValue(scopeName);
59
+ expect(first).toBe(1);
60
+ // The registry row and its backing sequence now exist.
61
+ const rows = await db
62
+ .select({ sequence_name: counterGroups.sequence_name })
63
+ .from(counterGroups)
64
+ .where(eq(counterGroups.group_name, scopeName));
65
+ expect(rows).toHaveLength(1);
66
+ });
67
+ it('is monotonic within a scope and never reuses a value', async () => {
68
+ const scopeName = scope('test:doc-b:files');
69
+ const a = await counters.nextScopedCounterValue(scopeName);
70
+ const b = await counters.nextScopedCounterValue(scopeName);
71
+ const c = await counters.nextScopedCounterValue(scopeName);
72
+ expect([a, b, c]).toEqual([1, 2, 3]);
73
+ });
74
+ it('scopes are independent — one scope does not advance another', async () => {
75
+ const scopeA = scope('test:doc-c:files');
76
+ const scopeB = scope('test:doc-d:files');
77
+ await counters.nextScopedCounterValue(scopeA);
78
+ await counters.nextScopedCounterValue(scopeA);
79
+ const bFirst = await counters.nextScopedCounterValue(scopeB);
80
+ expect(bFirst).toBe(1);
81
+ });
82
+ it('interoperates with nextCounterValue once registered (same sequence)', async () => {
83
+ const scopeName = scope('test:doc-e:files');
84
+ const viaScoped = await counters.nextScopedCounterValue(scopeName);
85
+ // After self-registration the static-path allocator sees the group too.
86
+ const viaStatic = await counters.nextCounterValue(scopeName);
87
+ expect(viaStatic).toBe(viaScoped + 1);
88
+ });
89
+ it('rejects an empty scope name', async () => {
90
+ await expect(counters.nextScopedCounterValue('')).rejects.toThrow(/scopeName must be a non-empty string/);
91
+ });
92
+ it('parallel allocations within one scope yield distinct values', async () => {
93
+ const scopeName = scope('test:doc-f:files');
94
+ const values = await Promise.all(Array.from({ length: 8 }, () => counters.nextScopedCounterValue(scopeName)));
95
+ const unique = new Set(values);
96
+ expect(unique.size).toBe(8);
97
+ expect(Math.min(...values)).toBe(1);
98
+ expect(Math.max(...values)).toBe(8);
99
+ });
100
+ });
@@ -51,6 +51,17 @@ function* flattenFieldSetDataGen(fields, data, locale, parent_path = []) {
51
51
  * @returns
52
52
  */
53
53
  function* flattenFieldDataGen(field, data, locale, field_path) {
54
+ // Virtual fields are never persisted. The value participated in the write
55
+ // pipeline (patches applied it, lifecycle hooks saw and could act on it),
56
+ // but no store_* row is written, so reads reconstruct the field as absent
57
+ // and the next editing session starts clean. This is the single
58
+ // enforcement point for the `virtual` contract — every nesting level
59
+ // (top-level, group children, array items, block fields) funnels through
60
+ // this function, and a virtual structure field omits its whole subtree.
61
+ // See `BaseField.virtual` in @byline/core.
62
+ if (field.virtual === true) {
63
+ return;
64
+ }
54
65
  // Treat `null` the same as `undefined` — a removed/cleared field emits no
55
66
  // storage rows for the new version, so reads reconstruct as absent. Without
56
67
  // this guard, downstream value extractors (relation, file/image, group,
@@ -324,3 +324,82 @@ describe('reserved field-name tolerance on restore', () => {
324
324
  expect(warnings, 'a reserved-name orphan must not be treated as an unknown field').toEqual([]);
325
325
  });
326
326
  });
327
+ describe('virtual fields', () => {
328
+ // Mirrors the publications shape that motivated `virtual`: a per-item
329
+ // "generate thumbnail" checkbox + page number inside an array group,
330
+ // plus a top-level virtual scalar. Virtual values must flow through the
331
+ // write pipeline (hooks see them) but emit NO store rows — reads
332
+ // reconstruct them as absent, so the next editing session starts clean.
333
+ const VirtualCollectionConfig = defineCollection({
334
+ path: 'virtual-docs',
335
+ labels: { singular: 'Virtual Doc', plural: 'Virtual Docs' },
336
+ fields: [
337
+ { name: 'title', type: 'text' },
338
+ { name: 'regenerateAll', type: 'checkbox', virtual: true, optional: true },
339
+ {
340
+ name: 'files',
341
+ type: 'array',
342
+ fields: [
343
+ {
344
+ name: 'filesGroup',
345
+ type: 'group',
346
+ fields: [
347
+ { name: 'label', type: 'text' },
348
+ { name: 'generateThumbnail', type: 'checkbox', virtual: true, optional: true },
349
+ { name: 'thumbnailPage', type: 'integer', virtual: true, defaultValue: 1 },
350
+ ],
351
+ },
352
+ ],
353
+ },
354
+ {
355
+ name: 'scratch',
356
+ type: 'group',
357
+ virtual: true,
358
+ optional: true,
359
+ fields: [{ name: 'note', type: 'text', optional: true }],
360
+ },
361
+ ],
362
+ });
363
+ const virtualDocument = {
364
+ title: 'Publication-ish',
365
+ regenerateAll: true,
366
+ files: [
367
+ {
368
+ _id: 'item-1',
369
+ filesGroup: { label: 'English PDF', generateThumbnail: true, thumbnailPage: 3 },
370
+ },
371
+ {
372
+ _id: 'item-2',
373
+ filesGroup: { label: 'Thai PDF', generateThumbnail: false, thumbnailPage: 1 },
374
+ },
375
+ ],
376
+ scratch: { note: 'never stored' },
377
+ };
378
+ it('emits no store rows for virtual fields at any nesting depth', () => {
379
+ const flattened = flattenFieldSetData(VirtualCollectionConfig.fields, virtualDocument, 'all');
380
+ const paths = flattened.map((row) => row.field_path.join('.'));
381
+ // Persisted values survive…
382
+ expect(paths).toContain('title');
383
+ expect(paths).toContain('files.0.filesGroup.label');
384
+ expect(paths).toContain('files.1.filesGroup.label');
385
+ // …virtual leaves do not (top-level, nested in array group)…
386
+ expect(paths.some((p) => p.includes('regenerateAll'))).toBe(false);
387
+ expect(paths.some((p) => p.includes('generateThumbnail'))).toBe(false);
388
+ expect(paths.some((p) => p.includes('thumbnailPage'))).toBe(false);
389
+ // …and a virtual structure field omits its whole subtree.
390
+ expect(paths.some((p) => p.startsWith('scratch'))).toBe(false);
391
+ });
392
+ it('round-trip reconstruction leaves virtual fields structurally absent', () => {
393
+ const flattened = flattenFieldSetData(VirtualCollectionConfig.fields, virtualDocument, 'all');
394
+ const { data: restored, warnings } = restoreFieldSetData(VirtualCollectionConfig.fields, flattened);
395
+ expect(warnings).toEqual([]);
396
+ expect(restored.title).toBe('Publication-ish');
397
+ expect(restored.regenerateAll).toBeUndefined();
398
+ expect(restored.scratch).toBeUndefined();
399
+ const items = restored.files;
400
+ expect(items).toHaveLength(2);
401
+ expect(items[0]?.filesGroup.label).toBe('English PDF');
402
+ expect(items[0]?.filesGroup.generateThumbnail).toBeUndefined();
403
+ expect(items[0]?.filesGroup.thumbnailPage).toBeUndefined();
404
+ });
405
+ });
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": "3.18.0",
5
+ "version": "3.20.0",
6
6
  "engines": {
7
7
  "node": ">=20.9.0"
8
8
  },
@@ -56,9 +56,9 @@
56
56
  "npm-run-all": "^4.1.5",
57
57
  "pg": "^8.22.0",
58
58
  "uuid": "^14.0.1",
59
- "@byline/auth": "3.18.0",
60
- "@byline/core": "3.18.0",
61
- "@byline/admin": "3.18.0"
59
+ "@byline/admin": "3.20.0",
60
+ "@byline/core": "3.20.0",
61
+ "@byline/auth": "3.20.0"
62
62
  },
63
63
  "devDependencies": {
64
64
  "@biomejs/biome": "2.5.2",