akanjs 3.1.1-rc.13 → 3.1.1-rc.15

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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "akanjs",
3
- "version": "3.1.1-rc.13",
3
+ "version": "3.1.1-rc.15",
4
4
  "sourceType": "module",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -198,8 +198,8 @@ export function cacheHtmlWhileStreaming(
198
198
  );
199
199
  }
200
200
 
201
- export function cancelStreamForHeadResponse(stream: ReadableStream<Uint8Array>, reason: unknown): void {
202
- void stream.cancel(reason).catch(() => {
201
+ export function cancelStreamForHeadResponse(stream: ReadableStream<Uint8Array>, message: string): void {
202
+ void stream.cancel(new DOMException(message, "AbortError")).catch(() => {
203
203
  });
204
204
  }
205
205
 
@@ -719,7 +719,7 @@ export class WebRouter {
719
719
 
720
720
  const lateControl = await Promise.race([rscResult.lateControl, Promise.resolve(null)]);
721
721
  if (lateControl?.type === "redirect") {
722
- cancelStreamForHeadResponse(htmlStream, new Error("redirected before the response started"));
722
+ cancelStreamForHeadResponse(htmlStream, "redirected before the response started");
723
723
  return createDocumentRedirectResponse(new URL(lateControl.location, url.origin), lateControl.status);
724
724
  }
725
725
  const responseStatus = lateControl?.type === "not-found" ? 404 : (rscResult.status ?? 200);
@@ -731,7 +731,7 @@ export class WebRouter {
731
731
  headers.set("X-Akan-Cache", "BYPASS");
732
732
  headers.set("X-Akan-Cache-Reason", htmlCacheDecision.reason);
733
733
  }
734
- cancelStreamForHeadResponse(htmlStream, new Error("HEAD response does not consume body"));
734
+ cancelStreamForHeadResponse(htmlStream, "HEAD response does not consume body");
735
735
  return new Response(null, { status: responseStatus, headers });
736
736
  }
737
737
  if (htmlCacheEntry && responseStatus === 200) {
@@ -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(data: DocumentRecord & { id: string }, { preserveTimestamps = false }: UpsertByIdOptions = {}) {
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") DO UPDATE SET ${kept}"updatedAt" = excluded."updatedAt", "removedAt" = excluded."removedAt", "_doc" = excluded."_doc"
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(this.hydrate(this.fromProjectedRow(row, projection), undefined, { track: false }), row),
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, StoredRow | undefined>)[SqlDocumentStore.#storedRow];
971
- await this.#writeChanges(id, stored ?? this.toRow(originalData), row, touch);
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 row = (doc as Record<symbol, StoredRow | undefined>)[SqlDocumentStore.#storedRow];
1013
- if (!row) return doc;
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(this.fromRow({ ...row, id: String(doc.id) }), undefined, { track: false });
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
- #withStoredRow<Doc extends object>(doc: Doc, row: StoredRow): Doc {
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, StoredRow>;
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;
@@ -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`.
@@ -18,7 +18,7 @@ export declare function cacheHtmlWhileStreaming(stream: ReadableStream<Uint8Arra
18
18
  maxBodyBytes?: number | null;
19
19
  onSkip?: (reason: "body-too-large" | "store-skip") => void;
20
20
  }): ReadableStream<Uint8Array>;
21
- export declare function cancelStreamForHeadResponse(stream: ReadableStream<Uint8Array>, reason: unknown): void;
21
+ export declare function cancelStreamForHeadResponse(stream: ReadableStream<Uint8Array>, message: string): void;
22
22
  export declare function resolveHtmlRouteCacheStoreTtl(input: {
23
23
  baseTtl: number;
24
24
  workerCacheState: RouteCacheRenderState;
@@ -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;
@@ -14,7 +14,7 @@ interface MsgOption {
14
14
 
15
15
  interface ArmedMessage {
16
16
  message: Omit<ToastMessage, "leaving">;
17
- timeoutId: ReturnType<typeof setTimeout>;
17
+ timeoutId: ReturnType<typeof setTimeout> | undefined;
18
18
  }
19
19
 
20
20
  const exitGraceMs = 2000;
@@ -64,7 +64,9 @@ export const Messages = () => {
64
64
  }
65
65
  running.set(message.key, {
66
66
  message,
67
- timeoutId: setTimeout(() => startLeaving(message.key), message.duration * 1000),
67
+
68
+ timeoutId:
69
+ message.duration > 0 ? setTimeout(() => startLeaving(message.key), message.duration * 1000) : undefined,
68
70
  });
69
71
  }
70
72
  for (const [key, armed] of [...running]) {