@proteinjs/db 1.56.0 → 1.58.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@proteinjs/db",
3
- "version": "1.56.0",
3
+ "version": "1.58.0",
4
4
  "main": "./dist/generated/index.js",
5
5
  "types": "./dist/generated/index.d.ts",
6
6
  "exports": {
@@ -66,5 +66,5 @@
66
66
  "ts-jest": "29.1.1",
67
67
  "typescript": "5.2.2"
68
68
  },
69
- "gitHead": "469b64193ef5d4fc0faa74ebe1f6c2af04dfd6cb"
69
+ "gitHead": "d11e74da6edf607eddb0844f00b61f912a22dcef"
70
70
  }
@@ -76,6 +76,23 @@ export class CursorWindowPager<T extends Record> {
76
76
  return { rows, nextAnchor: this.deriveNextAnchor(rows, windowSize) };
77
77
  }
78
78
 
79
+ /**
80
+ * THE ROWS PAST AN ANCHOR, COUNTED: how many rows the windows after `anchor` would serve — under this pager's
81
+ * own conditions and its lexicographic continuation (the same WHERE the windows read with, never a second
82
+ * filter), with no sort and no pagination: one COUNT at the database. A null anchor counts the whole data
83
+ * set. A consumer that sizes room for the rows a reader has not loaded yet (a transcript's rows above its
84
+ * loaded start, sized before the first paint) reads this beside the window it just loaded and holds an exact
85
+ * count where a guess would refill or collapse later.
86
+ */
87
+ async countPast(anchor: CursorAnchor | null): Promise<number> {
88
+ const qb = this.createQuery();
89
+ if (anchor) {
90
+ qb.or(this.anchorConditions(anchor));
91
+ }
92
+ const db = this.options?.db ?? getDb();
93
+ return await db.getRowCount(this.table, qb);
94
+ }
95
+
79
96
  static encodeAnchor(anchor: CursorAnchor): string {
80
97
  return Serializer.serialize(anchor);
81
98
  }
package/src/Db.ts CHANGED
@@ -167,6 +167,16 @@ export class Db<R extends Record = Record> implements DbService<R> {
167
167
  : this.getDefaultTransactionContextFactory();
168
168
  }
169
169
 
170
+ /**
171
+ * Whether this Db writes as the SYSTEM (`getDbAsSystem()`: no table-auth gate, no scope) rather
172
+ * than as the session's user. The Db a write hands its hooks ({@link newHookDb}) answers for the
173
+ * invoking write, so a watcher that must tell a user's write from a system one (archive on a
174
+ * user's delete; stand aside on a purge) reads the write's mode here instead of the session.
175
+ */
176
+ isSystem(): boolean {
177
+ return this.runAsSystem;
178
+ }
179
+
170
180
  static getDefaultDbDriver(): DbDriver {
171
181
  if (!getGlobal()[DEFAULT_DB_DRIVER_GLOBAL_KEY]) {
172
182
  const defaultDbDriverFactory = SourceRepository.get().object<DefaultDbDriverFactory>(
@@ -23,13 +23,18 @@ type Row = {
23
23
  const table = { name: 'pager_test' } as Table<Row & { created: moment.Moment; updated: moment.Moment }>;
24
24
  type PagerRow = Row & { created: moment.Moment; updated: moment.Moment };
25
25
 
26
- const staged: { rows: Row[] } = { rows: [] };
26
+ const staged: { rows: Row[]; count: number } = { rows: [], count: 0 };
27
27
  const executed: QueryBuilder<PagerRow>[] = [];
28
+ const counted: QueryBuilder<PagerRow>[] = [];
28
29
  const stubDb = {
29
30
  query: async (_table: unknown, qb: QueryBuilder<PagerRow>) => {
30
31
  executed.push(qb);
31
32
  return staged.rows;
32
33
  },
34
+ getRowCount: async (_table: unknown, qb: QueryBuilder<PagerRow>) => {
35
+ counted.push(qb);
36
+ return staged.count;
37
+ },
33
38
  } as unknown as Db;
34
39
 
35
40
  const createQuery = () => new QueryBuilder<PagerRow>(table.name);
@@ -183,3 +188,45 @@ describe('loadWindow', () => {
183
188
  await expect(pager().loadWindow(null, 0)).rejects.toThrow(/window size/i);
184
189
  });
185
190
  });
191
+
192
+ describe('countPast — the rows past an anchor, counted under the windows’ own WHERE', () => {
193
+ beforeEach(() => {
194
+ counted.length = 0;
195
+ });
196
+
197
+ it('an anchored count carries the lexicographic continuation and nothing else: no sort, no pagination; the db’s COUNT is the answer', async () => {
198
+ staged.count = 26;
199
+ const remaining = await pager([{ field: 'seq', desc: true }]).countPast({ values: [40, 'row-4'] });
200
+ expect(remaining).toBe(26);
201
+ expect(counted).toHaveLength(1);
202
+ const qb = counted[0];
203
+ // The same continuation a window after this anchor reads with: seq < 40 OR (seq = 40 AND id > 'row-4').
204
+ expect(conditionNodes(qb)).toEqual(
205
+ expect.arrayContaining([
206
+ expect.objectContaining({ field: 'seq', operator: '<', value: 40 }),
207
+ expect.objectContaining({ field: 'seq', operator: '=', value: 40 }),
208
+ expect.objectContaining({ field: 'id', operator: '>', value: 'row-4' }),
209
+ ])
210
+ );
211
+ expect(conditionNodes(qb)).toHaveLength(3);
212
+ expect(paginationNode(qb)).toBeUndefined();
213
+ expect(sortNodes(qb)).toHaveLength(0);
214
+ });
215
+
216
+ it('a null anchor counts the whole data set: no conditions beyond the consumer’s own', async () => {
217
+ staged.count = 3;
218
+ expect(await pager([{ field: 'seq', desc: true }]).countPast(null)).toBe(3);
219
+ expect(counted).toHaveLength(1);
220
+ expect(conditionNodes(counted[0])).toHaveLength(0);
221
+ expect(paginationNode(counted[0])).toBeUndefined();
222
+ });
223
+
224
+ it('every count builds on a FRESH query — a window’s cursor conditions never leak into it', async () => {
225
+ const instance = pager([{ field: 'seq', desc: true }]);
226
+ staged.rows = [];
227
+ await instance.loadWindow({ values: [40, 'row-4'] }, 10);
228
+ staged.count = 0;
229
+ await instance.countPast(null);
230
+ expect(conditionNodes(counted[0])).toHaveLength(0);
231
+ });
232
+ });
@@ -0,0 +1,173 @@
1
+ import { SourceRepository } from '@proteinjs/reflection';
2
+ import { Statement } from '@proteinjs/db-query';
3
+ import { Db, DbDriver, DbDriverDmlStatementConfig, getDb, getDbAsSystem } from '../src/Db';
4
+ import { Table } from '../src/Table';
5
+ import { Record, withRecordColumns } from '../src/Record';
6
+ import { StringColumn } from '../src/Columns';
7
+ import { TableWatcher } from '../src/TableWatcher';
8
+ import { TableWatcherRunner } from '../src/TableWatcherRunner';
9
+ import { DefaultTransactionContextFactory, TransactionContextData } from '../src/transaction/TransactionContextFactory';
10
+
11
+ /**
12
+ * `Db.isSystem()` — the public read of a write's MODE. A hook that must tell a user's write from
13
+ * the system's (a watcher that archives on a user's delete and stands aside on a purge) reads it
14
+ * off the Db the write hands it, instead of guessing from the session. Pinned both ways on the
15
+ * public factories, and — the pin that matters — on the Db the hook runner hands each watcher:
16
+ * that Db answers for the INVOKING write, whatever the session holds.
17
+ */
18
+
19
+ interface Doc extends Record {
20
+ title: string;
21
+ }
22
+
23
+ class DocTable extends Table<Doc> {
24
+ public name = 'is_system_doc';
25
+ /** Every door open to everyone: a session Db reaches the write with no user to resolve. */
26
+ public auth: Table<Doc>['auth'] = { db: { all: 'public' } };
27
+ public columns = withRecordColumns<Doc>({
28
+ title: new StringColumn('title'),
29
+ });
30
+ }
31
+
32
+ const docTable = new DocTable() as Table<Doc>;
33
+
34
+ /** What each hook was handed: the mode of its trailing Db. */
35
+ const handed: { phase: string; mode: boolean | undefined }[] = [];
36
+
37
+ class ModeRecordingWatcher implements TableWatcher<Doc> {
38
+ name(): string {
39
+ return 'ModeRecordingWatcher';
40
+ }
41
+
42
+ table(): Table<Doc> {
43
+ return docTable;
44
+ }
45
+
46
+ async beforeInsert<T extends Doc>(record: Omit<T, keyof Doc>, db?: Db): Promise<Omit<T, keyof Doc>> {
47
+ handed.push({ phase: 'beforeInsert', mode: db?.isSystem() });
48
+ return record;
49
+ }
50
+
51
+ async afterInsert<T extends Doc>(_record: T, db?: Db): Promise<void> {
52
+ handed.push({ phase: 'afterInsert', mode: db?.isSystem() });
53
+ }
54
+ }
55
+
56
+ class TestTransactionContextFactory implements DefaultTransactionContextFactory {
57
+ private context: TransactionContextData = {};
58
+
59
+ getTransactionContext(): TransactionContextData {
60
+ return this.context;
61
+ }
62
+
63
+ async runInContext<T>(context: TransactionContextData, fn: () => Promise<T>): Promise<T> {
64
+ const previous = this.context;
65
+ this.context = context;
66
+ try {
67
+ return await fn();
68
+ } finally {
69
+ this.context = previous;
70
+ }
71
+ }
72
+ }
73
+
74
+ /** Accepts every statement; no database behind it. */
75
+ class AcceptingDriver implements DbDriver {
76
+ getDbName(): string {
77
+ return 'test';
78
+ }
79
+
80
+ async createDbIfNotExists(): Promise<void> {}
81
+
82
+ async createDb(): Promise<void> {}
83
+
84
+ async dropDb(): Promise<void> {}
85
+
86
+ getTableManager(): never {
87
+ throw new Error('not used by these tests');
88
+ }
89
+
90
+ async runQuery(): Promise<never[]> {
91
+ return [];
92
+ }
93
+
94
+ async runDml(generateStatement: (config: DbDriverDmlStatementConfig) => Statement): Promise<number> {
95
+ generateStatement({
96
+ useParams: true,
97
+ useNamedParams: true,
98
+ prefixTablesWithDb: false,
99
+ getDriverColumnType: () => 'STRING(MAX)',
100
+ });
101
+ return 1;
102
+ }
103
+
104
+ async runTransaction<T>(fn: (transaction: unknown) => Promise<T>): Promise<T> {
105
+ return await fn({ id: 'transaction' });
106
+ }
107
+
108
+ getOperationDeadlineMs(): number {
109
+ return 1000;
110
+ }
111
+ }
112
+
113
+ type SourceRepositoryInternals = { objectCache: { [qualifiedName: string]: unknown[] } };
114
+ type TableWatcherRunnerStatics = { tableWatcherMap?: unknown };
115
+ const objectCache = () => (SourceRepository.get() as unknown as SourceRepositoryInternals).objectCache;
116
+ const DEFAULT_DRIVER_GLOBAL = '__proteinjs_db_defaultDbDriver';
117
+
118
+ const driver = new AcceptingDriver();
119
+ const transactions = new TestTransactionContextFactory();
120
+ const modesHandedBy = async (db: Db<Doc>): Promise<(boolean | undefined)[]> => {
121
+ handed.length = 0;
122
+ await db.insert(docTable, { title: 'row' });
123
+ expect(handed.map(({ phase }) => phase)).toEqual(['beforeInsert', 'afterInsert']);
124
+ return handed.map(({ mode }) => mode);
125
+ };
126
+
127
+ describe('Db.isSystem', () => {
128
+ let originalWatchers: unknown[] | undefined;
129
+ let originalFactories: unknown[] | undefined;
130
+ let originalTransactionFactories: unknown[] | undefined;
131
+
132
+ beforeAll(() => {
133
+ originalWatchers = objectCache()['@proteinjs/db/TableWatcher'];
134
+ originalFactories = objectCache()['@proteinjs/db/DefaultDbDriverFactory'];
135
+ originalTransactionFactories = objectCache()['@proteinjs/db/DefaultTransactionContextFactory'];
136
+ objectCache()['@proteinjs/db/TableWatcher'] = [new ModeRecordingWatcher()];
137
+ // The public factories resolve their driver and transaction context through reflection.
138
+ objectCache()['@proteinjs/db/DefaultDbDriverFactory'] = [{ getDbDriver: () => driver }];
139
+ objectCache()['@proteinjs/db/DefaultTransactionContextFactory'] = [transactions];
140
+ delete (globalThis as { [key: string]: unknown })[DEFAULT_DRIVER_GLOBAL];
141
+ (TableWatcherRunner as unknown as TableWatcherRunnerStatics).tableWatcherMap = undefined;
142
+ });
143
+
144
+ afterAll(() => {
145
+ const restore = (name: string, original: unknown[] | undefined) => {
146
+ if (original) {
147
+ objectCache()[name] = original;
148
+ } else {
149
+ delete objectCache()[name];
150
+ }
151
+ };
152
+ restore('@proteinjs/db/TableWatcher', originalWatchers);
153
+ restore('@proteinjs/db/DefaultDbDriverFactory', originalFactories);
154
+ restore('@proteinjs/db/DefaultTransactionContextFactory', originalTransactionFactories);
155
+ delete (globalThis as { [key: string]: unknown })[DEFAULT_DRIVER_GLOBAL];
156
+ (TableWatcherRunner as unknown as TableWatcherRunnerStatics).tableWatcherMap = undefined;
157
+ });
158
+
159
+ test('a system Db answers true, a session Db false — on the public factories and on the constructor', () => {
160
+ expect(getDbAsSystem().isSystem()).toBe(true);
161
+ expect(getDb().isSystem()).toBe(false);
162
+ expect(new Db<Doc>(driver, () => docTable, transactions, true).isSystem()).toBe(true);
163
+ expect(new Db<Doc>(driver, () => docTable, transactions).isSystem()).toBe(false);
164
+ });
165
+
166
+ test("the Db a write hands its watchers answers for THAT write: a system write's hooks read true", async () => {
167
+ expect(await modesHandedBy(getDbAsSystem<Doc>())).toEqual([true, true]);
168
+ });
169
+
170
+ test("the Db a write hands its watchers answers for THAT write: a session write's hooks read false", async () => {
171
+ expect(await modesHandedBy(getDb<Doc>())).toEqual([false, false]);
172
+ });
173
+ });