akanjs 3.1.1-rc.13 → 3.1.1-rc.14
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/document/into.ts +8 -0
- package/package.json +1 -1
- package/service/predefinedAdaptor/sql/SqlDocumentStore.ts +73 -11
- package/service/predefinedAdaptor/sql/dialect/postgres.ts +5 -0
- package/service/predefinedAdaptor/sql/dialect/sqlite.ts +8 -0
- package/service/predefinedAdaptor/sql/types.ts +8 -1
- package/types/document/into.d.ts +11 -0
- package/types/service/predefinedAdaptor/sql/SqlDocumentStore.d.ts +1 -1
- package/types/service/predefinedAdaptor/sql/dialect/postgres.d.ts +1 -0
- package/types/service/predefinedAdaptor/sql/dialect/sqlite.d.ts +1 -0
- package/types/service/predefinedAdaptor/sql/types.d.ts +8 -1
package/document/into.ts
CHANGED
|
@@ -93,6 +93,14 @@ export type Mdl<
|
|
|
93
93
|
purgeMany(query: _RawQuery): Promise<number>;
|
|
94
94
|
updateById(id: string, update: DocumentUpdateInput<_RawDoc>, options?: DocumentUpdateOptions): Promise<UpdateResult>;
|
|
95
95
|
removeById(id: string): Promise<UpdateResult>;
|
|
96
|
+
/**
|
|
97
|
+
* The same write, over a stored row only when the row matches `where`, a removed row only with `withRemoved`; a free
|
|
98
|
+
* id is inserted. `modifiedCount` is 0 when the stored row did not match, and `upsertedId` is set on an insert.
|
|
99
|
+
*/
|
|
100
|
+
upsertById(
|
|
101
|
+
data: Partial<_RawDoc> & { id: string },
|
|
102
|
+
options: { where: _RawQuery; withRemoved?: boolean; preserveTimestamps?: boolean },
|
|
103
|
+
): Promise<UpdateResult>;
|
|
96
104
|
/**
|
|
97
105
|
* Writes the record as the row under its own id, a new one or in place of the stored one, with no hook: a sync's or a
|
|
98
106
|
* backfill's write, which may run twice. `preserveTimestamps` keeps its `createdAt` and `updatedAt`.
|
package/package.json
CHANGED
|
@@ -85,6 +85,13 @@ interface StaleIndex {
|
|
|
85
85
|
|
|
86
86
|
type StoredRow = Omit<SqliteDocumentRow, "id">;
|
|
87
87
|
|
|
88
|
+
interface ProjectedRead {
|
|
89
|
+
row: ProjectedSqliteDocumentRow;
|
|
90
|
+
fields: string[];
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
type StoredRead = StoredRow | ProjectedRead;
|
|
94
|
+
|
|
88
95
|
const freshDefault = (value: unknown) => (Array.isArray(value) ? [...(value as unknown[])] : value);
|
|
89
96
|
|
|
90
97
|
export class SqlDocumentStore {
|
|
@@ -97,6 +104,7 @@ export class SqlDocumentStore {
|
|
|
97
104
|
#docPrototype: object | null = null;
|
|
98
105
|
#immutableKeys: string[] | null = null;
|
|
99
106
|
#ensured: Promise<void> | null = null;
|
|
107
|
+
#warnedUnread = false;
|
|
100
108
|
static readonly #logger = new Logger("SqlDocumentStore");
|
|
101
109
|
/** The row a document was read or last written as. */
|
|
102
110
|
static readonly #storedRow = Symbol("akan.storedRow");
|
|
@@ -387,7 +395,10 @@ export class SqlDocumentStore {
|
|
|
387
395
|
return this.create(data);
|
|
388
396
|
}
|
|
389
397
|
|
|
390
|
-
async upsertById(
|
|
398
|
+
async upsertById(
|
|
399
|
+
data: DocumentRecord & { id: string },
|
|
400
|
+
{ preserveTimestamps = false, where, withRemoved }: UpsertByIdOptions = {},
|
|
401
|
+
) {
|
|
391
402
|
if (!isDocumentId(data.id)) throw new Error(`Invalid ID value: ${data.id}`);
|
|
392
403
|
const now = dayjs();
|
|
393
404
|
const keepsCreatedAt = preserveTimestamps && data.createdAt != null;
|
|
@@ -398,18 +409,44 @@ export class SqlDocumentStore {
|
|
|
398
409
|
});
|
|
399
410
|
const row = this.toRow(prepared);
|
|
400
411
|
const kept = keepsCreatedAt ? `"createdAt" = excluded."createdAt", ` : "";
|
|
412
|
+
const update = `DO UPDATE SET ${kept}"updatedAt" = excluded."updatedAt", "removedAt" = excluded."removedAt", "_doc" = excluded."_doc"`;
|
|
413
|
+
if (where) return await this.#upsertWhere(data.id, row, update, where, { withRemoved });
|
|
401
414
|
const stored = await this.owner
|
|
402
415
|
.getConnection()
|
|
403
416
|
.prepare(
|
|
404
417
|
`INSERT INTO ${quoteIdent(this.table)} ("id", "createdAt", "updatedAt", "removedAt", "_doc") VALUES (?, ?, ?, ?, ${this.dialect.docValuePlaceholder()})
|
|
405
|
-
ON CONFLICT("id")
|
|
406
|
-
RETURNING "createdAt"`,
|
|
418
|
+
ON CONFLICT("id") ${update} RETURNING "createdAt"`,
|
|
407
419
|
)
|
|
408
420
|
.get<{ createdAt: number | string }>(row.id, row.createdAt, row.updatedAt, row.removedAt, row._doc);
|
|
409
421
|
const createdAt = Number(stored?.createdAt ?? row.createdAt);
|
|
410
422
|
return this.#withStoredRow(this.hydrate({ ...prepared, createdAt: dayjs(createdAt) }), { ...row, createdAt });
|
|
411
423
|
}
|
|
412
424
|
|
|
425
|
+
async #upsertWhere(id: string, row: StoredRow, update: string, where: DocumentQuery, scope: DocumentScopeOptions) {
|
|
426
|
+
const { where: condition, params } = this.writeQuery(where, "upsertById", scope);
|
|
427
|
+
const table = quoteIdent(this.table);
|
|
428
|
+
const columns = [...BASE_COLUMNS, "_doc"].map(
|
|
429
|
+
(column) => `${table}.${quoteIdent(column)} AS ${quoteIdent(column)}`,
|
|
430
|
+
);
|
|
431
|
+
const written = await this.owner
|
|
432
|
+
.getConnection()
|
|
433
|
+
.prepare(
|
|
434
|
+
this.dialect.upsertRow(
|
|
435
|
+
this.table,
|
|
436
|
+
`${update} WHERE EXISTS (SELECT 1 FROM (SELECT ${columns.join(", ")}) AS "stored" WHERE ${condition})`,
|
|
437
|
+
),
|
|
438
|
+
)
|
|
439
|
+
.get<{ inserted: boolean | number }>(id, row.createdAt, row.updatedAt, row.removedAt, row._doc, ...params);
|
|
440
|
+
if (!written) return { acknowledged: true, matchedCount: 0, modifiedCount: 0, upsertedId: null };
|
|
441
|
+
const inserted = Boolean(Number(written.inserted));
|
|
442
|
+
return {
|
|
443
|
+
acknowledged: true,
|
|
444
|
+
matchedCount: inserted ? 0 : 1,
|
|
445
|
+
modifiedCount: 1,
|
|
446
|
+
upsertedId: inserted ? id : null,
|
|
447
|
+
};
|
|
448
|
+
}
|
|
449
|
+
|
|
413
450
|
async update(id: string, patch: DocumentRecord, options: WriteHookOptions = {}) {
|
|
414
451
|
const current = await this.pickByIdForWrite(id);
|
|
415
452
|
return await this.writeUpdatedDocument(id, { ...current, ...patch, id }, current, options);
|
|
@@ -599,7 +636,13 @@ export class SqlDocumentStore {
|
|
|
599
636
|
`SELECT ${this.projectionSql(projection)} ${tail}`,
|
|
600
637
|
).all<ProjectedSqliteDocumentRow>(...args);
|
|
601
638
|
return rows.map((row) =>
|
|
602
|
-
this.#readAs(
|
|
639
|
+
this.#readAs(
|
|
640
|
+
this.#withStoredRow(this.hydrate(this.fromProjectedRow(row, projection), undefined, { track: false }), {
|
|
641
|
+
row,
|
|
642
|
+
fields: projection,
|
|
643
|
+
}),
|
|
644
|
+
row,
|
|
645
|
+
),
|
|
603
646
|
);
|
|
604
647
|
}
|
|
605
648
|
const rows = await this.prepareStmt(`SELECT ${star} ${tail}`).all<SqliteDocumentRow>(...args);
|
|
@@ -967,8 +1010,8 @@ export class SqlDocumentStore {
|
|
|
967
1010
|
if (runSaveHooks) await this.runHooks("save", crudType, doc, "pre", previous);
|
|
968
1011
|
await this.runHooks(crudType, crudType, doc, "pre", previous);
|
|
969
1012
|
const row = this.toRow(doc);
|
|
970
|
-
const stored = (originalData as Record<symbol,
|
|
971
|
-
await this.#writeChanges(id, stored
|
|
1013
|
+
const stored = (originalData as Record<symbol, StoredRead | undefined>)[SqlDocumentStore.#storedRow];
|
|
1014
|
+
await this.#writeChanges(id, stored && !("fields" in stored) ? stored : this.toRow(originalData), row, touch);
|
|
972
1015
|
await this.runHooks(crudType, crudType, doc, "post", previous);
|
|
973
1016
|
if (runSaveHooks) await this.runHooks("save", crudType, doc, "post", previous);
|
|
974
1017
|
return this.#withStoredRow(doc, row);
|
|
@@ -1009,10 +1052,19 @@ export class SqlDocumentStore {
|
|
|
1009
1052
|
}
|
|
1010
1053
|
|
|
1011
1054
|
#changesOf(doc: DocumentRecord): DocumentRecord {
|
|
1012
|
-
const
|
|
1013
|
-
if (!
|
|
1055
|
+
const stored = (doc as Record<symbol, StoredRead | undefined>)[SqlDocumentStore.#storedRow];
|
|
1056
|
+
if (!stored) {
|
|
1057
|
+
this.#warnUnread();
|
|
1058
|
+
return doc;
|
|
1059
|
+
}
|
|
1014
1060
|
|
|
1015
|
-
const reread = this.hydrate(
|
|
1061
|
+
const reread = this.hydrate(
|
|
1062
|
+
"fields" in stored
|
|
1063
|
+
? this.fromProjectedRow(stored.row, stored.fields)
|
|
1064
|
+
: this.fromRow({ ...stored, id: String(doc.id) }),
|
|
1065
|
+
undefined,
|
|
1066
|
+
{ track: false },
|
|
1067
|
+
);
|
|
1016
1068
|
const read = SqlDocumentStore.#encodedFields(this.toRow(reread));
|
|
1017
1069
|
const held = SqlDocumentStore.#encodedFields(this.toRow(doc));
|
|
1018
1070
|
const changes: DocumentRecord = {};
|
|
@@ -1030,7 +1082,17 @@ export class SqlDocumentStore {
|
|
|
1030
1082
|
return fields;
|
|
1031
1083
|
}
|
|
1032
1084
|
|
|
1033
|
-
#
|
|
1085
|
+
#warnUnread() {
|
|
1086
|
+
if (process.env.NODE_ENV === "production" || this.#warnedUnread) return;
|
|
1087
|
+
this.#warnedUnread = true;
|
|
1088
|
+
SqlDocumentStore.#logger.warn(
|
|
1089
|
+
`A ${this.table} document that was never read was saved, so every field it holds was written, undoing what ` +
|
|
1090
|
+
`other writes changed since it was built. Read the document before changing it, or write the fields with ` +
|
|
1091
|
+
`updateById().`,
|
|
1092
|
+
);
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
#withStoredRow<Doc extends object>(doc: Doc, row: StoredRead): Doc {
|
|
1034
1096
|
Object.defineProperty(doc, SqlDocumentStore.#storedRow, { value: row, configurable: true });
|
|
1035
1097
|
return doc;
|
|
1036
1098
|
}
|
|
@@ -1210,7 +1272,7 @@ export class SqlDocumentStore {
|
|
|
1210
1272
|
},
|
|
1211
1273
|
refresh: {
|
|
1212
1274
|
async value(this: DocumentRecord) {
|
|
1213
|
-
const fresh = (await store.pickById(this.id as string)) as DocumentRecord & Record<symbol,
|
|
1275
|
+
const fresh = (await store.pickById(this.id as string)) as DocumentRecord & Record<symbol, StoredRead>;
|
|
1214
1276
|
Object.assign(this, fresh);
|
|
1215
1277
|
Reflect.deleteProperty(this, SqlDocumentStore.#readRemoved);
|
|
1216
1278
|
return store.#withStoredRow(this, fresh[SqlDocumentStore.#storedRow]);
|
|
@@ -240,6 +240,11 @@ export class PostgresDialect implements SqlDialect {
|
|
|
240
240
|
}
|
|
241
241
|
return { sql, params };
|
|
242
242
|
}
|
|
243
|
+
|
|
244
|
+
upsertRow(table: string, conflict: string) {
|
|
245
|
+
return `INSERT INTO ${quoteIdent(table)} ("id", "createdAt", "updatedAt", "removedAt", "_doc") VALUES (?, ?, ?, ?, ${this.docValuePlaceholder()})
|
|
246
|
+
ON CONFLICT("id") ${conflict} RETURNING ("xmax" = 0) AS "inserted"`;
|
|
247
|
+
}
|
|
243
248
|
affectedRows(result: unknown): number {
|
|
244
249
|
const row = result as { count?: number } | Array<unknown> | null;
|
|
245
250
|
if (Array.isArray(row)) return (row as { count?: number }).count ?? row.length;
|
|
@@ -144,6 +144,14 @@ export class SqliteDialect implements SqlDialect {
|
|
|
144
144
|
if (removed.length) sql = `json_remove(${sql}, ${removed.map((field) => this.#path(field)).join(", ")})`;
|
|
145
145
|
return { sql, params: set.map(([, json]) => json) };
|
|
146
146
|
}
|
|
147
|
+
|
|
148
|
+
upsertRow(table: string, conflict: string) {
|
|
149
|
+
const target = quoteIdent(table);
|
|
150
|
+
return `WITH "incoming" AS MATERIALIZED (SELECT ? AS "id", ? AS "createdAt", ? AS "updatedAt", ? AS "removedAt", ${this.docValuePlaceholder()} AS "_doc"),
|
|
151
|
+
"prior" AS MATERIALIZED (SELECT count(*) AS "taken" FROM ${target} WHERE "id" = (SELECT "id" FROM "incoming"))
|
|
152
|
+
INSERT INTO ${target} ("id", "createdAt", "updatedAt", "removedAt", "_doc") SELECT "incoming".* FROM "incoming", "prior" WHERE true
|
|
153
|
+
ON CONFLICT("id") ${conflict} RETURNING (SELECT "taken" FROM "prior") = 0 AS "inserted"`;
|
|
154
|
+
}
|
|
147
155
|
affectedRows(result: unknown): number {
|
|
148
156
|
return Number((result as { changes?: number | bigint } | null)?.changes ?? 0);
|
|
149
157
|
}
|
|
@@ -47,9 +47,11 @@ export interface DatabaseConfig {
|
|
|
47
47
|
search?: SearchConfig;
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
-
export interface UpsertByIdOptions {
|
|
50
|
+
export interface UpsertByIdOptions extends DocumentScopeOptions {
|
|
51
51
|
/** Keep the record's own `createdAt` and `updatedAt`, as a backfill from another system must. */
|
|
52
52
|
preserveTimestamps?: boolean;
|
|
53
|
+
/** Write over a stored row only when it matches, a removed one only with `withRemoved`; a free id still inserts. */
|
|
54
|
+
where?: DocumentQuery;
|
|
53
55
|
}
|
|
54
56
|
|
|
55
57
|
export interface DocumentStore {
|
|
@@ -296,6 +298,11 @@ export interface SqlDialect {
|
|
|
296
298
|
/** The `definition` `indexesOf` reads back for `createIndex(props)`; absent where only the database can tell. */
|
|
297
299
|
indexDefinition?(props: CreateIndexProps): string;
|
|
298
300
|
applyUpdate(acc: string, op: DocumentUpdateOperator, path: string, value: unknown): SqlFrag;
|
|
301
|
+
/**
|
|
302
|
+
* One row's `INSERT … ON CONFLICT("id") <conflict>`, binding the id, `createdAt`, `updatedAt`, `removedAt` and
|
|
303
|
+
* `_doc` before the conflict clause's own values. Each row it wrote returns `"inserted"`, true when no row held the id.
|
|
304
|
+
*/
|
|
305
|
+
upsertRow(table: string, conflict: string): string;
|
|
299
306
|
/** The stored document with these top-level fields replaced (value as JSON text) and these removed. */
|
|
300
307
|
mergeDocument(set: [field: string, json: string][], removed: string[]): SqlFrag;
|
|
301
308
|
affectedRows(result: unknown): number;
|
package/types/document/into.d.ts
CHANGED
|
@@ -59,6 +59,17 @@ export type Mdl<Doc, Raw, _RawDoc = DocumentModel<Raw>, _RawQuery extends Docume
|
|
|
59
59
|
purgeMany(query: _RawQuery): Promise<number>;
|
|
60
60
|
updateById(id: string, update: DocumentUpdateInput<_RawDoc>, options?: DocumentUpdateOptions): Promise<UpdateResult>;
|
|
61
61
|
removeById(id: string): Promise<UpdateResult>;
|
|
62
|
+
/**
|
|
63
|
+
* The same write, over a stored row only when the row matches `where`, a removed row only with `withRemoved`; a free
|
|
64
|
+
* id is inserted. `modifiedCount` is 0 when the stored row did not match, and `upsertedId` is set on an insert.
|
|
65
|
+
*/
|
|
66
|
+
upsertById(data: Partial<_RawDoc> & {
|
|
67
|
+
id: string;
|
|
68
|
+
}, options: {
|
|
69
|
+
where: _RawQuery;
|
|
70
|
+
withRemoved?: boolean;
|
|
71
|
+
preserveTimestamps?: boolean;
|
|
72
|
+
}): Promise<UpdateResult>;
|
|
62
73
|
/**
|
|
63
74
|
* Writes the record as the row under its own id, a new one or in place of the stored one, with no hook: a sync's or a
|
|
64
75
|
* backfill's write, which may run twice. `preserveTimestamps` keeps its `createdAt` and `updatedAt`.
|
|
@@ -21,7 +21,7 @@ export declare class SqlDocumentStore {
|
|
|
21
21
|
}): Promise<any>;
|
|
22
22
|
upsertById(data: DocumentRecord & {
|
|
23
23
|
id: string;
|
|
24
|
-
}, { preserveTimestamps }?: UpsertByIdOptions): Promise<any>;
|
|
24
|
+
}, { preserveTimestamps, where, withRemoved }?: UpsertByIdOptions): Promise<any>;
|
|
25
25
|
update(id: string, patch: DocumentRecord, options?: WriteHookOptions): Promise<any>;
|
|
26
26
|
remove(id: string): Promise<any>;
|
|
27
27
|
/** Up to `limit` stored rows after the id `after`, in id order and removed ones included. */
|
|
@@ -33,5 +33,6 @@ export declare class PostgresDialect implements SqlDialect {
|
|
|
33
33
|
indexesOf(table: string): SqlFrag;
|
|
34
34
|
applyUpdate(acc: string, op: DocumentUpdateOperator, path: string, value: unknown): SqlFrag;
|
|
35
35
|
mergeDocument(set: [field: string, json: string][], removed: string[]): SqlFrag;
|
|
36
|
+
upsertRow(table: string, conflict: string): string;
|
|
36
37
|
affectedRows(result: unknown): number;
|
|
37
38
|
}
|
|
@@ -29,5 +29,6 @@ export declare class SqliteDialect implements SqlDialect {
|
|
|
29
29
|
indexDefinition(props: CreateIndexProps): string;
|
|
30
30
|
applyUpdate(acc: string, op: DocumentUpdateOperator, path: string, value: unknown): SqlFrag;
|
|
31
31
|
mergeDocument(set: [field: string, json: string][], removed: string[]): SqlFrag;
|
|
32
|
+
upsertRow(table: string, conflict: string): string;
|
|
32
33
|
affectedRows(result: unknown): number;
|
|
33
34
|
}
|
|
@@ -31,9 +31,11 @@ export interface DatabaseConfig {
|
|
|
31
31
|
postgres?: PostgresDatabaseConfig;
|
|
32
32
|
search?: SearchConfig;
|
|
33
33
|
}
|
|
34
|
-
export interface UpsertByIdOptions {
|
|
34
|
+
export interface UpsertByIdOptions extends DocumentScopeOptions {
|
|
35
35
|
/** Keep the record's own `createdAt` and `updatedAt`, as a backfill from another system must. */
|
|
36
36
|
preserveTimestamps?: boolean;
|
|
37
|
+
/** Write over a stored row only when it matches, a removed one only with `withRemoved`; a free id still inserts. */
|
|
38
|
+
where?: DocumentQuery;
|
|
37
39
|
}
|
|
38
40
|
export interface DocumentStore {
|
|
39
41
|
ensure(): Promise<void>;
|
|
@@ -296,6 +298,11 @@ export interface SqlDialect {
|
|
|
296
298
|
/** The `definition` `indexesOf` reads back for `createIndex(props)`; absent where only the database can tell. */
|
|
297
299
|
indexDefinition?(props: CreateIndexProps): string;
|
|
298
300
|
applyUpdate(acc: string, op: DocumentUpdateOperator, path: string, value: unknown): SqlFrag;
|
|
301
|
+
/**
|
|
302
|
+
* One row's `INSERT … ON CONFLICT("id") <conflict>`, binding the id, `createdAt`, `updatedAt`, `removedAt` and
|
|
303
|
+
* `_doc` before the conflict clause's own values. Each row it wrote returns `"inserted"`, true when no row held the id.
|
|
304
|
+
*/
|
|
305
|
+
upsertRow(table: string, conflict: string): string;
|
|
299
306
|
/** The stored document with these top-level fields replaced (value as JSON text) and these removed. */
|
|
300
307
|
mergeDocument(set: [field: string, json: string][], removed: string[]): SqlFrag;
|
|
301
308
|
affectedRows(result: unknown): number;
|