@proteinjs/db 1.34.2 → 1.34.4
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/CHANGELOG.md +24 -0
- package/dist/generated/index.d.ts +0 -4
- package/dist/generated/index.d.ts.map +1 -1
- package/dist/generated/index.js +1 -5
- package/dist/generated/index.js.map +1 -1
- package/dist/generated/test/index.d.ts +0 -4
- package/dist/generated/test/index.d.ts.map +1 -1
- package/dist/generated/test/index.js +3 -5
- package/dist/generated/test/index.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/src/Db.d.ts +12 -0
- package/dist/src/Db.d.ts.map +1 -1
- package/dist/src/Db.js +132 -22
- package/dist/src/Db.js.map +1 -1
- package/dist/src/RecordAccessError.d.ts +18 -0
- package/dist/src/RecordAccessError.d.ts.map +1 -0
- package/dist/src/RecordAccessError.js +46 -0
- package/dist/src/RecordAccessError.js.map +1 -0
- package/dist/src/Table.d.ts +28 -1
- package/dist/src/Table.d.ts.map +1 -1
- package/dist/src/source/SourceRecord.d.ts +35 -2
- package/dist/src/source/SourceRecord.d.ts.map +1 -1
- package/dist/src/source/SourceRecord.js +8 -1
- package/dist/src/source/SourceRecord.js.map +1 -1
- package/dist/src/source/SourceRecordLoader.d.ts +98 -7
- package/dist/src/source/SourceRecordLoader.d.ts.map +1 -1
- package/dist/src/source/SourceRecordLoader.js +348 -93
- package/dist/src/source/SourceRecordLoader.js.map +1 -1
- package/dist/src/tables/MigrationTable.d.ts +2 -0
- package/dist/src/tables/MigrationTable.d.ts.map +1 -1
- package/dist/test/reusable/SourceRecordSyncTests.d.ts.map +1 -1
- package/dist/test/reusable/SourceRecordSyncTests.js +803 -27
- package/dist/test/reusable/SourceRecordSyncTests.js.map +1 -1
- package/dist/test/util/tables/cascadeDeleteTestTables.d.ts +15 -115
- package/dist/test/util/tables/cascadeDeleteTestTables.d.ts.map +1 -1
- package/dist/test/util/tables/cascadeDeleteTestTables.js.map +1 -1
- package/dist/test/util/tables/crudTestTables.d.ts +2 -22
- package/dist/test/util/tables/crudTestTables.d.ts.map +1 -1
- package/dist/test/util/tables/crudTestTables.js.map +1 -1
- package/dist/test/util/tables/dynamicReferenceColumnTestTables.d.ts +4 -36
- package/dist/test/util/tables/dynamicReferenceColumnTestTables.d.ts.map +1 -1
- package/dist/test/util/tables/dynamicReferenceColumnTestTables.js.map +1 -1
- package/dist/test/util/tables/sourceRecordSyncTestTables.d.ts +20 -34
- package/dist/test/util/tables/sourceRecordSyncTestTables.d.ts.map +1 -1
- package/dist/test/util/tables/sourceRecordSyncTestTables.js +23 -1
- package/dist/test/util/tables/sourceRecordSyncTestTables.js.map +1 -1
- package/dist/test/util/tables/tableManagerTestTables.d.ts +3 -38
- package/dist/test/util/tables/tableManagerTestTables.d.ts.map +1 -1
- package/dist/test/util/tables/tableManagerTestTables.js.map +1 -1
- package/dist/test/util/tables/transactionTestTables.d.ts +2 -22
- package/dist/test/util/tables/transactionTestTables.d.ts.map +1 -1
- package/dist/test/util/tables/transactionTestTables.js.map +1 -1
- package/generated/index.ts +1 -5
- package/generated/test/index.ts +3 -5
- package/index.ts +1 -0
- package/package.json +5 -5
- package/src/Db.ts +65 -2
- package/src/RecordAccessError.ts +22 -0
- package/src/Table.ts +33 -1
- package/src/source/SourceRecord.ts +42 -4
- package/src/source/SourceRecordLoader.ts +333 -48
- package/test/reusable/SourceRecordSyncTests.ts +462 -11
- package/test/util/tables/cascadeDeleteTestTables.ts +15 -15
- package/test/util/tables/crudTestTables.ts +2 -2
- package/test/util/tables/dynamicReferenceColumnTestTables.ts +4 -4
- package/test/util/tables/sourceRecordSyncTestTables.ts +24 -4
- package/test/util/tables/tableManagerTestTables.ts +3 -3
- package/test/util/tables/transactionTestTables.ts +2 -2
- package/test.d.ts +2 -0
- package/test.js +4 -0
- package/tsconfig.json +2 -1
package/index.ts
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@proteinjs/db",
|
|
3
|
-
"version": "1.34.
|
|
3
|
+
"version": "1.34.4",
|
|
4
4
|
"main": "./dist/generated/index.js",
|
|
5
5
|
"types": "./dist/generated/index.d.ts",
|
|
6
6
|
"exports": {
|
|
@@ -41,9 +41,9 @@
|
|
|
41
41
|
"test": "jest --passWithNoTests"
|
|
42
42
|
},
|
|
43
43
|
"dependencies": {
|
|
44
|
-
"@proteinjs/db-query": "^1.7.
|
|
44
|
+
"@proteinjs/db-query": "^1.7.2",
|
|
45
45
|
"@proteinjs/logger": "^1.0.21",
|
|
46
|
-
"@proteinjs/reflection": "^1.
|
|
46
|
+
"@proteinjs/reflection": "^1.2.0",
|
|
47
47
|
"@proteinjs/serializer": "^1.1.10",
|
|
48
48
|
"@proteinjs/server-api": "^3.0.11",
|
|
49
49
|
"@proteinjs/service": "^1.5.1",
|
|
@@ -53,7 +53,7 @@
|
|
|
53
53
|
"uuid": "8.3.0"
|
|
54
54
|
},
|
|
55
55
|
"devDependencies": {
|
|
56
|
-
"@proteinjs/reflection-build": "^
|
|
56
|
+
"@proteinjs/reflection-build": "^2.0.1",
|
|
57
57
|
"@types/jest": "29.5.5",
|
|
58
58
|
"@types/node": "14.0.27",
|
|
59
59
|
"@types/uuid": "8.3.0",
|
|
@@ -66,5 +66,5 @@
|
|
|
66
66
|
"ts-jest": "29.1.1",
|
|
67
67
|
"typescript": "5.2.2"
|
|
68
68
|
},
|
|
69
|
-
"gitHead": "
|
|
69
|
+
"gitHead": "6fe97aa884e73cdeed9cfc7b4a0c12b396657057"
|
|
70
70
|
}
|
package/src/Db.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { DbService, Query, QueryOptions, getDbService } from './services/DbService';
|
|
1
|
+
import { DbService, ObjectQuery, Query, QueryOptions, getDbService } from './services/DbService';
|
|
2
2
|
import { Service } from '@proteinjs/service';
|
|
3
3
|
import { Loadable, SourceRepository } from '@proteinjs/reflection';
|
|
4
4
|
import {
|
|
@@ -177,6 +177,7 @@ export class Db<R extends Record = Record> implements DbService<R> {
|
|
|
177
177
|
this.statementConfigFactory.getStatementConfig(config)
|
|
178
178
|
);
|
|
179
179
|
await this.dbDriver.runDml(generateInsert, this.transactionForDriver());
|
|
180
|
+
await this.runColumnAfterInsertHooks(table, recordCopy);
|
|
180
181
|
await this.tableWatcherRunner.runAfterInsertTableWatchers(table, recordCopy as T);
|
|
181
182
|
return recordCopy as T;
|
|
182
183
|
}
|
|
@@ -220,6 +221,12 @@ export class Db<R extends Record = Record> implements DbService<R> {
|
|
|
220
221
|
this.statementConfigFactory.getStatementConfig(config)
|
|
221
222
|
);
|
|
222
223
|
const recordUpdateCount = await this.dbDriver.runDml(generateUpdate, this.transactionForDriver());
|
|
224
|
+
if (!this.runAsSystem && recordUpdateCount === 0) {
|
|
225
|
+
const id = this.singleRowIdTarget(record, query);
|
|
226
|
+
if (id !== undefined) {
|
|
227
|
+
await this.runZeroRowFilteredWriteHooks(table, id, 'write');
|
|
228
|
+
}
|
|
229
|
+
}
|
|
223
230
|
await this.tableWatcherRunner.runAfterUpdateTableWatchers(table, recordUpdateCount, recordCopy, qb);
|
|
224
231
|
return recordUpdateCount;
|
|
225
232
|
}
|
|
@@ -254,6 +261,13 @@ export class Db<R extends Record = Record> implements DbService<R> {
|
|
|
254
261
|
qb.condition({ field: 'id', operator: '=', value: update.recordId as T[keyof T] });
|
|
255
262
|
const rows = await this._query(table, qb);
|
|
256
263
|
if (rows.length === 0) {
|
|
264
|
+
// Same id-targeted single-row refusal path as `update`/`delete`: 0 rows for a non-system
|
|
265
|
+
// caller can mean "row filtered out because the caller lacks write" — surface that as a
|
|
266
|
+
// typed refusal rather than a silent 0 (an existence-honest no-op still returns 0, since the
|
|
267
|
+
// hook only throws when the row exists AND the caller lacks write on its permission source).
|
|
268
|
+
if (!this.runAsSystem && typeof update.recordId === 'string') {
|
|
269
|
+
await this.runZeroRowFilteredWriteHooks(table, update.recordId, 'write');
|
|
270
|
+
}
|
|
257
271
|
return 0;
|
|
258
272
|
}
|
|
259
273
|
|
|
@@ -298,6 +312,11 @@ export class Db<R extends Record = Record> implements DbService<R> {
|
|
|
298
312
|
qb.condition({ field: 'id', operator: '=', value: record.id as T[keyof T] });
|
|
299
313
|
const rows = await this._query(table, qb);
|
|
300
314
|
if (rows.length === 0) {
|
|
315
|
+
// See `updateArrayMembership`: fire the id-targeted zero-row refusal so a capability-denied
|
|
316
|
+
// preserving write surfaces a typed error instead of a silent 0.
|
|
317
|
+
if (!this.runAsSystem && typeof record.id === 'string') {
|
|
318
|
+
await this.runZeroRowFilteredWriteHooks(table, record.id, 'write');
|
|
319
|
+
}
|
|
301
320
|
return 0;
|
|
302
321
|
}
|
|
303
322
|
|
|
@@ -323,6 +342,12 @@ export class Db<R extends Record = Record> implements DbService<R> {
|
|
|
323
342
|
await this.addColumnQueries(table, qb, 'delete');
|
|
324
343
|
const recordsToDelete = await this._query(table, qb);
|
|
325
344
|
if (recordsToDelete.length == 0) {
|
|
345
|
+
if (!this.runAsSystem) {
|
|
346
|
+
const id = this.singleRowIdTarget(undefined, query);
|
|
347
|
+
if (id !== undefined) {
|
|
348
|
+
await this.runZeroRowFilteredWriteHooks(table, id, 'delete');
|
|
349
|
+
}
|
|
350
|
+
}
|
|
326
351
|
return 0;
|
|
327
352
|
}
|
|
328
353
|
|
|
@@ -663,7 +688,45 @@ export class Db<R extends Record = Record> implements DbService<R> {
|
|
|
663
688
|
for (const columnPropertyName in table.columns) {
|
|
664
689
|
const column = (table.columns as any)[columnPropertyName] as Column<any, any>;
|
|
665
690
|
if (column.options?.onBeforeInsert) {
|
|
666
|
-
await column.options.onBeforeInsert(record, this.runAsSystem);
|
|
691
|
+
await column.options.onBeforeInsert(table, record, this.runAsSystem);
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
/** Post-DML mirror of {@link addColumnInsertHooks} — see `ColumnOptions.onAfterInsert`. */
|
|
697
|
+
private async runColumnAfterInsertHooks(table: Table<any>, record: any) {
|
|
698
|
+
for (const columnPropertyName in table.columns) {
|
|
699
|
+
const column = (table.columns as any)[columnPropertyName] as Column<any, any>;
|
|
700
|
+
if (column.options?.onAfterInsert) {
|
|
701
|
+
await column.options.onAfterInsert(table, record, this.runAsSystem);
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
/**
|
|
707
|
+
* The single-row id a filtered write targets, or undefined when the write is not a single-row
|
|
708
|
+
* id target. Only these qualify for the `onZeroRowFilteredWrite` hook, so a multi-row filtered
|
|
709
|
+
* write that matches nothing is never mistaken for a capability denial. An arbitrary
|
|
710
|
+
* `QueryBuilder` is deliberately NOT introspected (returns undefined) — the id-target paths that
|
|
711
|
+
* matter (`update(table, { id, ... })` and `delete(table, { id })`) come through `record.id` or
|
|
712
|
+
* an `ObjectQuery`.
|
|
713
|
+
*/
|
|
714
|
+
private singleRowIdTarget<T extends R>(record?: Partial<T>, query?: Query<T>): string | undefined {
|
|
715
|
+
if (query === undefined) {
|
|
716
|
+
return typeof record?.id === 'string' ? record.id : undefined;
|
|
717
|
+
}
|
|
718
|
+
if (isInstanceOf(query, QueryBuilder)) {
|
|
719
|
+
return undefined;
|
|
720
|
+
}
|
|
721
|
+
const objectQuery = query as ObjectQuery<T>;
|
|
722
|
+
return typeof objectQuery.id === 'string' ? objectQuery.id : undefined;
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
private async runZeroRowFilteredWriteHooks(table: Table<any>, id: string, operation: 'write' | 'delete') {
|
|
726
|
+
for (const columnPropertyName in table.columns) {
|
|
727
|
+
const column = (table.columns as any)[columnPropertyName] as Column<any, any>;
|
|
728
|
+
if (column.options?.onZeroRowFilteredWrite) {
|
|
729
|
+
await column.options.onZeroRowFilteredWrite(table, id, operation, this.runAsSystem);
|
|
667
730
|
}
|
|
668
731
|
}
|
|
669
732
|
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A ROW-LEVEL capability denial (distinct from `TableAuthError`, which denies at the table door).
|
|
3
|
+
*
|
|
4
|
+
* Raised when a caller passes the table's door auth but lacks the per-row capability a
|
|
5
|
+
* capability-scoped column enforces (e.g. a `SharedRecord`'s permission grants): inserting into a
|
|
6
|
+
* permission scope the caller cannot write, or an id-targeted single-row write that matched zero
|
|
7
|
+
* rows purely because the caller's grant level is insufficient. The point is to turn what used to
|
|
8
|
+
* be a SILENT zero-row result — which a caller (and an agent tool acting for them) cannot tell
|
|
9
|
+
* apart from a genuine no-op — into a signal a client can surface as "you don't have access".
|
|
10
|
+
*
|
|
11
|
+
* Name-tagged rather than relying on `instanceof`: the prototype chain is unreliable across
|
|
12
|
+
* package compile targets (same reason `TableAuthError` / `ServiceError` check `name`).
|
|
13
|
+
*/
|
|
14
|
+
export class RecordAccessError extends Error {
|
|
15
|
+
constructor(message: string) {
|
|
16
|
+
super(message);
|
|
17
|
+
this.name = 'RecordAccessError';
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const isRecordAccessError = (error: unknown): error is RecordAccessError =>
|
|
22
|
+
!!error && typeof error === 'object' && (error as { name?: string }).name === 'RecordAccessError';
|
package/src/Table.ts
CHANGED
|
@@ -169,7 +169,39 @@ export type ColumnOptions = {
|
|
|
169
169
|
immutable?: boolean | ((runAsSystem: boolean) => boolean);
|
|
170
170
|
/** Add conditions to query; called on every query of this table */
|
|
171
171
|
addToQuery?: (qb: QueryBuilder, runAsSystem: boolean, operation: 'read' | 'write' | 'delete') => Promise<void>;
|
|
172
|
-
|
|
172
|
+
/**
|
|
173
|
+
* Called before the row's insert DML runs, after defaults and before-insert table watchers.
|
|
174
|
+
* Runs where inserts execute (`Db.insert` — the server; a browser only ever proxies or queues),
|
|
175
|
+
* so guards here hold regardless of where column DEFAULTS were applied (the client `Transaction`
|
|
176
|
+
* path applies them in the browser). Throw to refuse the insert.
|
|
177
|
+
*/
|
|
178
|
+
onBeforeInsert?: (table: Table<any>, insertObj: any & Record, runAsSystem: boolean) => Promise<void>;
|
|
179
|
+
/**
|
|
180
|
+
* Called after the row's insert DML SUCCEEDS, before after-insert table watchers. The seam for
|
|
181
|
+
* side-effect writes that are part of the row's birth (e.g. `SharedRecord`'s platform-conferred
|
|
182
|
+
* owner grant): running server-side keeps column defaults pure enough for driverless client
|
|
183
|
+
* contexts, and running after the DML means a failed insert (duplicate id, refused guard) can
|
|
184
|
+
* never leave the side effect behind.
|
|
185
|
+
*/
|
|
186
|
+
onAfterInsert?: (table: Table<any>, insertObj: any & Record, runAsSystem: boolean) => Promise<void>;
|
|
187
|
+
/**
|
|
188
|
+
* Called after an id-targeted SINGLE-ROW filtered write (update or delete) on this table matched
|
|
189
|
+
* ZERO rows in a NON-system context. A column that narrows row visibility by capability (e.g.
|
|
190
|
+
* `SharedRecord`'s permission subquery) implements this to turn a silent 0 — which the caller
|
|
191
|
+
* cannot distinguish from a genuine capability denial — into a typed error. Return normally to
|
|
192
|
+
* leave the 0 as a legitimate no-op (row genuinely absent, or value unchanged for a caller who
|
|
193
|
+
* DOES hold the capability); throw (a `RecordAccessError`) to surface the refusal.
|
|
194
|
+
*
|
|
195
|
+
* Scope is deliberately narrow: only single-row id targets fire it, so a legitimate multi-row
|
|
196
|
+
* filtered write that matches nothing is never mis-flagged, and system-context maintenance
|
|
197
|
+
* writes never reach it.
|
|
198
|
+
*/
|
|
199
|
+
onZeroRowFilteredWrite?: (
|
|
200
|
+
table: Table<any>,
|
|
201
|
+
id: string,
|
|
202
|
+
operation: 'write' | 'delete',
|
|
203
|
+
runAsSystem: boolean
|
|
204
|
+
) => Promise<void>;
|
|
173
205
|
ui?: {
|
|
174
206
|
hidden?: boolean;
|
|
175
207
|
};
|
|
@@ -1,10 +1,24 @@
|
|
|
1
1
|
import { Loadable, SourceRepository } from '@proteinjs/reflection';
|
|
2
2
|
import { Columns, Table, getTables } from '../Table';
|
|
3
3
|
import { Record as DbRecord, withRecordColumns } from '../Record';
|
|
4
|
-
import { BooleanColumn } from '../Columns';
|
|
4
|
+
import { BooleanColumn, StringColumn } from '../Columns';
|
|
5
5
|
|
|
6
|
-
|
|
7
|
-
|
|
6
|
+
/**
|
|
7
|
+
* A source record declaration paired with its owning source: the package that compiled the
|
|
8
|
+
* declaration into this build (from the declaration's reflection qualified name). The source is
|
|
9
|
+
* the ownership grain of the sync — {@link SourceRecordLoader} stamps it on every row it writes
|
|
10
|
+
* and prunes only within it, so servers running different builds against one shared database
|
|
11
|
+
* never delete each other's rows.
|
|
12
|
+
*/
|
|
13
|
+
export type SourceRecordLoaderDeclaration<T extends SourceRecord = SourceRecord> = {
|
|
14
|
+
source: string;
|
|
15
|
+
loader: SourceRecordLoader<T>;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export const getSourceRecordLoaders = <T extends SourceRecord = SourceRecord>(): SourceRecordLoaderDeclaration<T>[] =>
|
|
19
|
+
SourceRepository.get()
|
|
20
|
+
.objectsWithNames<SourceRecordLoader<T>>('@proteinjs/db/SourceRecordLoader')
|
|
21
|
+
.map(({ packageName, object }) => ({ source: packageName, loader: object }));
|
|
8
22
|
|
|
9
23
|
export function getSourceRecordTables() {
|
|
10
24
|
const tables = getTables();
|
|
@@ -31,11 +45,31 @@ export function isSourceRecordTable(table: Table<any>) {
|
|
|
31
45
|
|
|
32
46
|
export interface SourceRecord extends DbRecord {
|
|
33
47
|
isLoadedFromSource?: boolean;
|
|
48
|
+
/**
|
|
49
|
+
* The package whose declaration owns this row (the declaring loader's package, from its
|
|
50
|
+
* reflection qualified name). Stamped by {@link SourceRecordLoader} on every row it writes;
|
|
51
|
+
* the removed-reconcile prunes only rows whose `sourcePackage` matches a package the running
|
|
52
|
+
* build actually declares from — so a build never deletes rows owned by a package it does not
|
|
53
|
+
* carry (e.g. another server's types on a shared database). Rows written before this column
|
|
54
|
+
* existed carry NULL until their owning package's next boot adopts and stamps them.
|
|
55
|
+
*/
|
|
56
|
+
sourcePackage?: string;
|
|
57
|
+
/**
|
|
58
|
+
* The declaring package's version at the time this row was last stamped (from the package's
|
|
59
|
+
* own package.json, resolved at runtime). This is the ordering WITHIN a package that makes
|
|
60
|
+
* version skew safe on a shared database: a boot never prunes, flags, or rewrites a row stamped
|
|
61
|
+
* by a strictly NEWER version of the same package, so an older build cannot delete the types a
|
|
62
|
+
* newer build added or churn the ones it redefined. NULL (legacy rows, or builds whose package
|
|
63
|
+
* version could not be resolved) carries no ordering and keeps the last-writer-wins semantics.
|
|
64
|
+
*/
|
|
65
|
+
sourcePackageVersion?: string;
|
|
34
66
|
}
|
|
35
67
|
|
|
36
68
|
const getSourceRecordColumns = (hideFromUi = true) => {
|
|
37
69
|
return {
|
|
38
70
|
isLoadedFromSource: new BooleanColumn('is_loaded_from_source', { ui: { hidden: hideFromUi } }),
|
|
71
|
+
sourcePackage: new StringColumn('source_package', { ui: { hidden: hideFromUi } }),
|
|
72
|
+
sourcePackageVersion: new StringColumn('source_package_version', { ui: { hidden: hideFromUi } }),
|
|
39
73
|
};
|
|
40
74
|
};
|
|
41
75
|
|
|
@@ -78,7 +112,11 @@ type OptionalProperties<T> = Pick<
|
|
|
78
112
|
*
|
|
79
113
|
* On Db.init, the record will be inserted if it doesn't exist, and updated if it does exist to mirror what is in source.
|
|
80
114
|
*
|
|
81
|
-
* If the SourceRecordLoader is deleted from source, the record will be deleted from the db on server startup
|
|
115
|
+
* If the SourceRecordLoader is deleted from source, the record will be deleted from the db on server startup (per the
|
|
116
|
+
* table's `onSourceRemoved` policy) — by the next boot of a build that still carries the declaring package at its
|
|
117
|
+
* version or newer. Ownership is per package: a boot only reconciles rows owned by packages it carries, and never
|
|
118
|
+
* rows a newer version of the same package stamped; a package removed from every build leaves its rows behind.
|
|
119
|
+
* This will also be the behavior if id is changed - the record with the old id will be deleted.
|
|
82
120
|
*/
|
|
83
121
|
export interface SourceRecordLoader<T extends SourceRecord> extends Loadable {
|
|
84
122
|
table: Table<T>;
|