akanjs 3.1.0-rc.24 → 3.1.0-rc.26

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.
@@ -96,6 +96,8 @@ type QueryMethodOfKey<
96
96
  [K in `remove${CapitalizedK}`]: (...args: _Args) => Promise<UpdateResult>;
97
97
  } & {
98
98
  [K in `removeOne${CapitalizedK}`]: (...args: _Args) => Promise<UpdateResult>;
99
+ } & {
100
+ [K in `purge${CapitalizedK}`]: (...args: _Args) => Promise<number>;
99
101
  } & {
100
102
  [K in `update${CapitalizedK}`]: (...args: _Args) => UpdateChain<Doc>;
101
103
  } & {
@@ -154,6 +156,7 @@ export type DatabaseInstanceWithQuerySort<
154
156
  __remove: (id: string) => Promise<Doc>;
155
157
  __removeMany: (query: _QueryOfDoc) => Promise<UpdateResult>;
156
158
  __removeOne: (query: _QueryOfDoc) => Promise<UpdateResult>;
159
+ __purgeMany: (query: _QueryOfDoc) => Promise<number>;
157
160
  __updateMany: (query: _QueryOfDoc, update: DocumentUpdateInput<Doc>) => Promise<UpdateResult>;
158
161
  __updateOne: (query: _QueryOfDoc, update: DocumentUpdateInput<Doc>) => Promise<UpdateResult>;
159
162
  __list(query: _QueryOfDoc, queryOption?: _ListQueryOption): Promise<Doc[]>;
package/document/into.ts CHANGED
@@ -83,6 +83,8 @@ export type Mdl<
83
83
  ): Promise<UpdateResult>;
84
84
  removeOne(query: _RawQuery): Promise<UpdateResult>;
85
85
  removeMany(query: _RawQuery): Promise<UpdateResult>;
86
+ /** Deletes every row the query matches, removed ones too, and answers how many: no hook, cascade or undo. */
87
+ purgeMany(query: _RawQuery): Promise<number>;
86
88
  updateById(id: string, update: DocumentUpdateInput<_RawDoc>, options?: DocumentUpdateOptions): Promise<UpdateResult>;
87
89
  removeById(id: string): Promise<UpdateResult>;
88
90
  /**
@@ -14,15 +14,17 @@ import type {
14
14
  SliceCls,
15
15
  } from "akanjs/signal";
16
16
 
17
- type ExtendedEndpointReturn<ClientReturns, Full, Light, Insight> = ClientReturns extends (infer R)[]
18
- ? ExtendedEndpointReturn<R, Full, Light, Insight>[]
19
- : Full extends ClientReturns
20
- ? Full
21
- : Light extends ClientReturns
22
- ? Light
23
- : Insight extends ClientReturns
24
- ? Insight
25
- : ClientReturns;
17
+ type ExtendedEndpointReturn<ClientReturns, Full, Light, Insight> = unknown extends ClientReturns
18
+ ? ClientReturns
19
+ : ClientReturns extends (infer R)[]
20
+ ? ExtendedEndpointReturn<R, Full, Light, Insight>[]
21
+ : Full extends ClientReturns
22
+ ? Full
23
+ : Light extends ClientReturns
24
+ ? Light
25
+ : Insight extends ClientReturns
26
+ ? Insight
27
+ : ClientReturns;
26
28
 
27
29
  type EndpointClientReturns<E, SlceCls extends SliceCls | never> = [SlceCls] extends [never]
28
30
  ? EndpInfoClientReturns<E>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "akanjs",
3
- "version": "3.1.0-rc.24",
3
+ "version": "3.1.0-rc.26",
4
4
  "sourceType": "module",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -291,6 +291,7 @@ export class DatabaseResolver {
291
291
  timedQuery(() => store.updateManyByQuery(query, update, ...scopeArgs(options))),
292
292
  removeOne: (query: QueryOf<any>) => timedQuery(() => store.removeOneByQuery(query)),
293
293
  removeMany: (query: QueryOf<any>) => timedQuery(() => store.removeManyByQuery(query)),
294
+ purgeMany: (query: QueryOf<any>) => timedQuery(() => store.purgeManyByQuery(query)),
294
295
  updateById: (id: string, update: DocumentUpdateInput, options?: DocumentUpdateOptions) =>
295
296
  timedQuery(() => store.updateOneByQuery({ id }, update, options)),
296
297
  removeById: (id: string) => timedQuery(() => store.removeOneByQuery({ id })),
@@ -397,6 +398,9 @@ export class DatabaseResolver {
397
398
  async __removeOne(query: QueryOf<any>) {
398
399
  return await timedQuery(() => this.__store.removeOneByQuery(query));
399
400
  }
401
+ async __purgeMany(query: QueryOf<any>) {
402
+ return await timedQuery(() => this.__store.purgeManyByQuery(query));
403
+ }
400
404
  async __updateMany(query: QueryOf<any>, update: DocumentUpdateInput) {
401
405
  return await timedQuery(() => this.__store.updateManyByQuery(query, update));
402
406
  }
@@ -35,6 +35,9 @@ export class ServiceResolver {
35
35
  async __removeOne(this: DatabaseService, query: QueryOf<any>) {
36
36
  return await this.__databaseModel.__removeOne(query);
37
37
  },
38
+ async __purgeMany(this: DatabaseService, query: QueryOf<any>) {
39
+ return await this.__databaseModel.__purgeMany(query);
40
+ },
38
41
  async __updateMany(this: DatabaseService, query: QueryOf<any>, update: DocumentUpdateInput) {
39
42
  return await this.__databaseModel.__updateMany(query, update);
40
43
  },
@@ -5,7 +5,7 @@ import path from "node:path";
5
5
  import type { PromiseOrObject } from "akanjs/base";
6
6
  import type { ConstantModel } from "akanjs/constant";
7
7
  import type { DatabaseModel, DocumentSchema, SchemaOf } from "akanjs/document";
8
- import type { Sql } from "postgres";
8
+ import type { ReservedSql, Sql } from "postgres";
9
9
  import { adapt } from "../adapt";
10
10
  import { ScheduleAdaptorRole } from "./role.adaptor";
11
11
  import {
@@ -185,6 +185,16 @@ export class SqliteDatabase
185
185
  .run(key, value, Date.now());
186
186
  }
187
187
 
188
+ listMeta(prefix: string) {
189
+ return this.#db
190
+ .query(`SELECT "key", "value" FROM "_akan_meta" WHERE substr("key", 1, ?) = ?`)
191
+ .all(prefix.length, prefix) as { key: string; value: string }[];
192
+ }
193
+
194
+ async deleteMeta(key: string) {
195
+ await this.#client.prepare(`DELETE FROM "_akan_meta" WHERE "key" = ?`).run(key);
196
+ }
197
+
188
198
  async transaction<T>(fn: () => PromiseOrObject<T>): Promise<T> {
189
199
  const active = this.#transaction.getStore();
190
200
  if (active) return await fn();
@@ -438,6 +448,16 @@ export class PostgresDatabase
438
448
  .run(key, value, Date.now());
439
449
  }
440
450
 
451
+ async listMeta(prefix: string) {
452
+ return await this.getConnection()
453
+ .prepare(`SELECT "key", "value" FROM "_akan_meta" WHERE substr("key", 1, $1) = $2`)
454
+ .all<{ key: string; value: string }>(prefix.length, prefix);
455
+ }
456
+
457
+ async deleteMeta(key: string) {
458
+ await this.getConnection().prepare(`DELETE FROM "_akan_meta" WHERE "key" = $1`).run(key);
459
+ }
460
+
441
461
  async transaction<T>(fn: () => PromiseOrObject<T>): Promise<T> {
442
462
  const active = this.#transaction.getStore();
443
463
  if (active) return await fn();
@@ -491,35 +511,47 @@ export class PostgresDatabase
491
511
  }
492
512
 
493
513
  async buildIndexConcurrently({ name, next, create, replace }: ConcurrentIndexBuild) {
514
+ await this.#underIndexLock(name, async (reserved) => {
515
+ const valid = async (index: string) =>
516
+ (
517
+ await reserved.unsafe(`SELECT indisvalid AS "valid" FROM pg_index WHERE indexrelid = to_regclass($1)`, [
518
+ quoteIdent(index),
519
+ ])
520
+ ).at(0)?.valid as boolean | undefined;
521
+ if (!replace) {
522
+ const state = await valid(name);
523
+ if (state) return;
524
+ if (state === false) await reserved.unsafe(`DROP INDEX CONCURRENTLY IF EXISTS ${quoteIdent(name)}`);
525
+ await reserved.unsafe(create(name));
526
+ return;
527
+ }
528
+
529
+ await reserved.unsafe(`DROP INDEX CONCURRENTLY IF EXISTS ${quoteIdent(next)}`);
530
+ try {
531
+ await reserved.unsafe(create(next));
532
+ } catch (error) {
533
+ await reserved.unsafe(`DROP INDEX CONCURRENTLY IF EXISTS ${quoteIdent(next)}`);
534
+ throw error;
535
+ }
536
+ await reserved.unsafe(
537
+ `DROP INDEX IF EXISTS ${quoteIdent(name)}; ALTER INDEX ${quoteIdent(next)} RENAME TO ${quoteIdent(name)}`,
538
+ );
539
+ });
540
+ }
541
+
542
+ async dropIndexConcurrently(name: string) {
543
+ await this.#underIndexLock(name, async (reserved) => {
544
+ await reserved.unsafe(`DROP INDEX CONCURRENTLY IF EXISTS ${quoteIdent(name)}`);
545
+ });
546
+ }
547
+
548
+ async #underIndexLock(name: string, fn: (reserved: ReservedSql) => Promise<void>) {
494
549
  const reserved = await this.#sql.reserve();
495
550
  const key = `akan:index:${name}`;
496
- const valid = async (index: string) =>
497
- (
498
- await reserved.unsafe(`SELECT indisvalid AS "valid" FROM pg_index WHERE indexrelid = to_regclass($1)`, [
499
- quoteIdent(index),
500
- ])
501
- ).at(0)?.valid as boolean | undefined;
502
551
  try {
503
552
  await reserved.unsafe(`SELECT pg_advisory_lock(hashtext(current_schema() || $1))`, [key]);
504
553
  try {
505
- if (!replace) {
506
- const state = await valid(name);
507
- if (state) return;
508
- if (state === false) await reserved.unsafe(`DROP INDEX CONCURRENTLY IF EXISTS ${quoteIdent(name)}`);
509
- await reserved.unsafe(create(name));
510
- return;
511
- }
512
-
513
- await reserved.unsafe(`DROP INDEX CONCURRENTLY IF EXISTS ${quoteIdent(next)}`);
514
- try {
515
- await reserved.unsafe(create(next));
516
- } catch (error) {
517
- await reserved.unsafe(`DROP INDEX CONCURRENTLY IF EXISTS ${quoteIdent(next)}`);
518
- throw error;
519
- }
520
- await reserved.unsafe(
521
- `DROP INDEX IF EXISTS ${quoteIdent(name)}; ALTER INDEX ${quoteIdent(next)} RENAME TO ${quoteIdent(name)}`,
522
- );
554
+ await fn(reserved);
523
555
  } finally {
524
556
  await reserved.unsafe(`SELECT pg_advisory_unlock(hashtext(current_schema() || $1))`, [key]);
525
557
  }
@@ -5,6 +5,7 @@ import { Err } from "akanjs/dictionary";
5
5
  import {
6
6
  createDocumentId,
7
7
  type DatabaseModel,
8
+ type DocumentIndexDescriptor,
8
9
  type DocumentQuery,
9
10
  type DocumentSchema,
10
11
  type DocumentScopeOptions,
@@ -66,12 +67,17 @@ interface DeclaredIndex {
66
67
  next: string;
67
68
  metaKey: string;
68
69
  hash: string;
69
- fields: Record<string, unknown>;
70
- /** A key ordered neither 1 nor -1, as MongoDB's `"text"`: no SQL index here stands for it. */
71
- unbuilt: boolean;
70
+ /** Every descriptor that declares this very index, so a copy an earlier release built under any of them serves it. */
71
+ hashes: Set<string>;
72
72
  create: (name: string, concurrently: boolean) => string;
73
73
  }
74
74
 
75
+ interface StaleIndex {
76
+ name: string;
77
+ metaKey: string;
78
+ exists: boolean;
79
+ }
80
+
75
81
  type StoredRow = Omit<SqliteDocumentRow, "id">;
76
82
 
77
83
  const freshDefault = (value: unknown) => (Array.isArray(value) ? [...(value as unknown[])] : value);
@@ -91,6 +97,7 @@ export class SqlDocumentStore {
91
97
  static readonly #storedRow = Symbol("akan.storedRow");
92
98
  /** Set on a document read as removed, which only a `withRemoved` read hands out. */
93
99
  static readonly #readRemoved = Symbol("akan.readRemoved");
100
+ static readonly #purgeBatchRows = 1000;
94
101
 
95
102
  constructor(
96
103
  private readonly owner: DocumentDatabaseOwner,
@@ -114,8 +121,8 @@ export class SqlDocumentStore {
114
121
 
115
122
  async #ensure() {
116
123
  this.assertValidRefName(this.table);
117
- const declared = await this.#declaredIndexes();
118
- const indexes = declared.filter((index) => !index.unbuilt);
124
+ const indexes = await this.#declaredIndexes();
125
+ for (const index of this.schema.indexes) if (!SqlDocumentStore.#builds(index)) this.#warnUnbuilt(index);
119
126
  const createSchema = async () => {
120
127
  const db = this.owner.getConnection();
121
128
  const existed = !!this.owner.buildIndexConcurrently && !!(await this.owner.hasTable?.(this.table));
@@ -134,9 +141,10 @@ export class SqlDocumentStore {
134
141
  `table:${this.table}`,
135
142
  await descriptorHash({ table: this.table, columns: ["id", "createdAt", "updatedAt", "removedAt", "_doc"] }),
136
143
  );
137
- for (const index of declared) if (index.unbuilt) await this.#skipUnbuiltIndex(index, indexes);
144
+ const { adopted, stale } = await this.#reconcileIndexes(indexes);
138
145
  const concurrent: (DeclaredIndex & { replace: boolean })[] = [];
139
146
  for (const index of indexes) {
147
+ if (adopted.has(index.name)) continue;
140
148
  const stored = await this.owner.getMeta(index.metaKey);
141
149
  const replace = !!stored && stored !== index.hash;
142
150
  if (replace)
@@ -155,51 +163,141 @@ export class SqlDocumentStore {
155
163
  else await db.execute(index.create(index.name, false));
156
164
  await this.owner.setMeta(index.metaKey, index.hash);
157
165
  }
158
- return concurrent;
166
+ const drops: StaleIndex[] = [];
167
+ for (const index of stale) {
168
+ if (!index.exists) await this.owner.deleteMeta(index.metaKey);
169
+ else if (this.owner.dropIndexConcurrently) drops.push(index);
170
+ else await this.#dropStaleIndex(index);
171
+ }
172
+ return { concurrent, drops };
159
173
  };
160
- const concurrent = this.owner.lockSchema ? await this.owner.lockSchema(createSchema) : await createSchema();
174
+ const { concurrent, drops } = this.owner.lockSchema
175
+ ? await this.owner.lockSchema(createSchema)
176
+ : await createSchema();
161
177
  for (const { name, next, create, replace, metaKey, hash } of concurrent) {
162
178
  await this.owner.buildIndexConcurrently?.({ name, next, create: (target) => create(target, true), replace });
163
179
  await this.owner.setMeta(metaKey, hash);
164
180
  }
181
+ for (const index of drops) await this.#dropStaleIndex(index);
165
182
  await this.owner.getSearchIndex()?.ensureRef(this.constant, this.database);
166
183
  }
167
184
 
185
+ static #builds(index: DocumentIndexDescriptor) {
186
+ return Object.values(index.fields).every((order) => order === 1 || order === -1);
187
+ }
188
+
168
189
  async #declaredIndexes(): Promise<DeclaredIndex[]> {
169
- return await Promise.all(
170
- this.schema.indexes.map(async (index, idx) => {
171
- const declared = index.name ?? `${this.table}_${Object.keys(index.fields).map(toSafeRefName).join("_")}_${idx}`;
172
- this.assertValidRefName(declared);
173
- const name = this.dialect.indexName(declared);
174
- const unbuilt = Object.values(index.fields).some((order) => order !== 1 && order !== -1);
175
- const columns = unbuilt
176
- ? []
177
- : Object.keys(index.fields).map((path) => ({
178
- path,
179
- expr: this.compiler.fieldExpr(path),
180
- isArray: this.compiler.isArrayPath(path),
181
- }));
182
- const predicate = unbuilt || !index.where ? undefined : this.#indexPredicate(index.where, name);
190
+ const candidates = await Promise.all(
191
+ this.schema.indexes.filter(SqlDocumentStore.#builds).map(async (descriptor) => {
192
+ const paths = Object.keys(descriptor.fields);
193
+ const base =
194
+ descriptor.name ??
195
+ [this.table, ...paths.map(toSafeRefName), ...(descriptor.unique ? ["unique"] : [])].join("_");
196
+ const columns = paths.map((path) => ({
197
+ path,
198
+ expr: this.compiler.fieldExpr(path),
199
+ isArray: this.compiler.isArrayPath(path),
200
+ }));
201
+ const predicate = descriptor.where ? this.#indexPredicate(descriptor.where, base) : undefined;
202
+ const create = (target: string, concurrently: boolean) =>
203
+ this.dialect.createIndex({
204
+ name: target,
205
+ table: this.table,
206
+ unique: !!descriptor.unique,
207
+ columns,
208
+ concurrently,
209
+ where: predicate,
210
+ });
211
+ const definition = create("", false);
183
212
  return {
184
- name,
185
- next: this.dialect.indexName(`${declared}_next`),
186
- metaKey: `index:${this.table}:${name}`,
213
+ named: !!descriptor.name,
214
+ base,
215
+ definition,
187
216
 
188
- hash: await descriptorHash(predicate ? { ...index, predicate } : index),
189
- fields: index.fields,
190
- unbuilt,
191
- create: (target: string, concurrently: boolean) =>
192
- this.dialect.createIndex({
193
- name: target,
194
- table: this.table,
195
- unique: !!index.unique,
196
- columns,
197
- concurrently,
198
- where: predicate,
199
- }),
217
+ hash: await descriptorHash(predicate ? { ...descriptor, predicate } : descriptor),
218
+ suffix: (await descriptorHash(definition)).slice(0, 8),
219
+ create,
200
220
  };
201
221
  }),
202
222
  );
223
+ const definitionsOf = new Map<string, Set<string>>();
224
+ for (const { base, definition } of candidates)
225
+ definitionsOf.set(base, (definitionsOf.get(base) ?? new Set()).add(definition));
226
+ const namedDefinitions = new Set(candidates.filter(({ named }) => named).map(({ definition }) => definition));
227
+ const indexes = new Map<string, DeclaredIndex>();
228
+ for (const { named, base, definition, hash, suffix, create } of candidates) {
229
+ if (!named && namedDefinitions.has(definition)) continue;
230
+
231
+ const shared = (definitionsOf.get(base)?.size ?? 0) > 1;
232
+ if (
233
+ named &&
234
+ shared &&
235
+ candidates.some((other) => other.named && other.base === base && other.definition !== definition)
236
+ )
237
+ throw new Error(`Two different indexes on ${this.table} are both named ${base}`);
238
+ const declared = named || !shared ? base : `${base}_${suffix}`;
239
+ this.assertValidRefName(declared);
240
+ const name = this.dialect.indexName(declared);
241
+ const existing = indexes.get(name);
242
+ if (existing) {
243
+ existing.hashes.add(hash);
244
+ continue;
245
+ }
246
+ indexes.set(name, {
247
+ name,
248
+ next: this.dialect.indexName(`${declared}_next`),
249
+ metaKey: `index:${this.table}:${name}`,
250
+ hash,
251
+ hashes: new Set([hash]),
252
+ create,
253
+ });
254
+ }
255
+ return [...indexes.values()];
256
+ }
257
+
258
+ async #reconcileIndexes(indexes: DeclaredIndex[]) {
259
+ const prefix = `index:${this.table}:`;
260
+ const { sql, params } = this.dialect.indexesOf(this.table);
261
+ const rows = await this.owner
262
+ .getConnection()
263
+ .prepare(sql)
264
+ .all<{ name: string; valid: unknown }>(...params);
265
+ const physical = new Map(rows.map(({ name, valid }) => [name, !!valid]));
266
+ const declared = new Set(indexes.map(({ name }) => name));
267
+ const recorded = (await this.owner.listMeta(prefix)).map(({ key, value }) => ({
268
+ name: key.slice(prefix.length),
269
+ metaKey: key,
270
+ hash: value,
271
+ }));
272
+ const recordedNames = new Set(recorded.map(({ name }) => name));
273
+ const undeclared = recorded.filter(({ name }) => !declared.has(name)).sort((a, b) => a.name.localeCompare(b.name));
274
+
275
+ const kept = new Set<string>();
276
+ const adopted = new Set<string>();
277
+ for (const index of indexes) {
278
+ if (recordedNames.has(index.name) || physical.has(index.name)) continue;
279
+ const copy = undeclared.find(({ name, hash }) => !kept.has(name) && index.hashes.has(hash) && physical.get(name));
280
+ if (!copy) continue;
281
+ kept.add(copy.name);
282
+ adopted.add(index.name);
283
+ }
284
+ const stale: StaleIndex[] = undeclared
285
+ .filter(({ name }) => !kept.has(name))
286
+ .map(({ name, metaKey }) => ({ name, metaKey, exists: physical.has(name) }));
287
+ return { adopted, stale };
288
+ }
289
+
290
+ async #dropStaleIndex({ name, metaKey }: StaleIndex) {
291
+ try {
292
+ if (this.owner.dropIndexConcurrently) await this.owner.dropIndexConcurrently(name);
293
+ else await this.owner.getConnection().execute(`DROP INDEX IF EXISTS ${quoteIdent(name)}`);
294
+ await this.owner.deleteMeta(metaKey);
295
+ SqlDocumentStore.#logger.info(`Dropped index ${name} on ${this.table}, which no declaration builds any more`);
296
+ } catch (error) {
297
+ SqlDocumentStore.#logger.warn(
298
+ `Index ${name} on ${this.table} is no longer declared, and dropping it failed: ${error instanceof Error ? error.message : String(error)}`,
299
+ );
300
+ }
203
301
  }
204
302
 
205
303
  #indexPredicate(where: DocumentQuery, name: string) {
@@ -209,20 +307,13 @@ export class SqlDocumentStore {
209
307
  return sql === "1 = 1" ? undefined : inlineParams(sql, params);
210
308
  }
211
309
 
212
- async #skipUnbuiltIndex(index: DeclaredIndex, built: DeclaredIndex[]) {
213
- const dropped =
214
- (await this.owner.getMeta(index.metaKey)) === index.hash && !built.some(({ name }) => name === index.name);
215
- if (dropped) {
216
- await this.owner.getConnection().execute(`DROP INDEX IF EXISTS ${quoteIdent(index.name)}`);
217
-
218
- await this.owner.setMeta(index.metaKey, "");
219
- }
310
+ #warnUnbuilt(index: DocumentIndexDescriptor) {
220
311
  const paths = Object.keys(index.fields);
221
312
  SqlDocumentStore.#logger.warn(
222
313
  `Index ${JSON.stringify(index.fields)} on ${this.table} is not built: only 1 and -1 order a key, and "text" is ` +
223
314
  `MongoDB's text index, which as a B-tree would copy each whole value and fail long writes on Postgres. Search ` +
224
315
  `${paths.join(", ")} through the full-text role, field(String, { text: "desc" }), or declare ` +
225
- `{ ${paths[0]}: 1 } to look it up by equality.${dropped ? ` Dropped ${index.name}, which an earlier boot built from it.` : ""}`,
316
+ `{ ${paths[0]}: 1 } to look it up by equality.`,
226
317
  );
227
318
  }
228
319
 
@@ -397,6 +488,43 @@ export class SqlDocumentStore {
397
488
  return await this.#updateOne(query, { removedAt: dayjs() }, {}, "removeOneByQuery");
398
489
  }
399
490
 
491
+ async purgeManyByQuery(query: DocumentQuery) {
492
+
493
+ if (SqlDocumentStore.#matchesEveryRow(query))
494
+ throw new Error(
495
+ `purgeManyByQuery on "${this.table}" matches every row; a purge cannot be undone, so name a condition`,
496
+ );
497
+ const { where, params, joins } = this.compiler.compile(query);
498
+ if (joins.length)
499
+ throw new Error(
500
+ `q.search() cannot be used in purgeManyByQuery on "${this.table}"; query-level writes take no join.`,
501
+ );
502
+ const table = quoteIdent(this.table);
503
+ const batch = SqlDocumentStore.#purgeBatchRows;
504
+
505
+ const statement = this.owner
506
+ .getConnection()
507
+ .prepare(
508
+ `DELETE FROM ${table} WHERE ${quoteIdent("id")} IN (SELECT ${quoteIdent("id")} FROM ${table} WHERE ${where} LIMIT ${batch}) AND (${where})`,
509
+ );
510
+ let purged = 0;
511
+ for (;;) {
512
+ const changes = this.dialect.affectedRows(await statement.run(...params, ...params));
513
+ purged += changes;
514
+ if (changes < batch) return purged;
515
+
516
+ await new Promise((resolve) => setTimeout(resolve, 0));
517
+ }
518
+ }
519
+
520
+ static #matchesEveryRow(query: DocumentQuery): boolean {
521
+ if (!isDocumentQueryNode(query)) return Object.keys(query).length === 0;
522
+ if (query.kind === "all") return query.queries.every((sub) => SqlDocumentStore.#matchesEveryRow(sub));
523
+ if (query.kind === "any")
524
+ return !query.queries.length || query.queries.some((sub) => SqlDocumentStore.#matchesEveryRow(sub));
525
+ return false;
526
+ }
527
+
400
528
  private compiledUpdate(update: DocumentUpdate) {
401
529
  for (const raw of Object.values(update))
402
530
  assertStorableJson(jsonStr(isDocumentUpdateNode(raw) ? raw.value : raw), this.table);
@@ -171,6 +171,13 @@ export class PostgresDialect implements SqlDialect {
171
171
 
172
172
  return `${create} (${columns.map(({ expr }) => `${expr} NULLS FIRST`).join(", ")})${predicate}`;
173
173
  }
174
+
175
+ indexesOf(table: string): SqlFrag {
176
+ return {
177
+ sql: `SELECT c.relname AS "name", i.indisvalid AS "valid" FROM pg_index i JOIN pg_class c ON c.oid = i.indexrelid WHERE i.indrelid = to_regclass(?) AND NOT i.indisprimary`,
178
+ params: [quoteIdent(table)],
179
+ };
180
+ }
174
181
  applyUpdate(acc: string, op: DocumentUpdateOperator, path: string, value: unknown): SqlFrag {
175
182
  const p = this.#path(path);
176
183
 
@@ -93,6 +93,13 @@ export class SqliteDialect implements SqlDialect {
93
93
  createIndex({ name, table, unique, columns, where }: CreateIndexProps) {
94
94
  return `CREATE ${unique ? "UNIQUE " : ""}INDEX IF NOT EXISTS ${quoteIdent(name)} ON ${quoteIdent(table)} (${columns.map(({ expr }) => expr).join(", ")})${where ? ` WHERE ${where}` : ""}`;
95
95
  }
96
+
97
+ indexesOf(table: string): SqlFrag {
98
+ return {
99
+ sql: `SELECT "name", 1 AS "valid" FROM "sqlite_master" WHERE "type" = 'index' AND "tbl_name" = ? AND "sql" IS NOT NULL`,
100
+ params: [table],
101
+ };
102
+ }
96
103
  applyUpdate(acc: string, op: DocumentUpdateOperator, path: string, value: unknown): SqlFrag {
97
104
  const p = this.#path(path);
98
105
 
@@ -74,6 +74,7 @@ export interface DocumentStore {
74
74
  removeOneByQuery(
75
75
  query: DocumentQuery,
76
76
  ): Promise<{ acknowledged: boolean; matchedCount: number; modifiedCount: number; upsertedId: string | null }>;
77
+ purgeManyByQuery(query: DocumentQuery): Promise<number>;
77
78
  bulkWrite(
78
79
  operations: { updateOne: { filter: DocumentQuery; update: DocumentUpdateInput; upsert?: boolean } }[],
79
80
  ): Promise<{ acknowledged: boolean; matchedCount: number; modifiedCount: number; upsertedId: string | null }>;
@@ -193,6 +194,8 @@ export interface DocumentDatabaseOwner {
193
194
  getSearchIndex(): SearchIndex | null;
194
195
  getMeta(key: string): Promise<string | undefined> | string | undefined;
195
196
  setMeta(key: string, value: string): Promise<void>;
197
+ listMeta(prefix: string): Promise<{ key: string; value: string }[]> | { key: string; value: string }[];
198
+ deleteMeta(key: string): Promise<void>;
196
199
  afterCommit(fn: () => PromiseOrObject<void>): Promise<void>;
197
200
  transaction?<T>(fn: () => PromiseOrObject<T>): Promise<T>;
198
201
  /** Runs `fn` as the only schema change: Postgres races two `CREATE … IF NOT EXISTS` into a duplicate-key error. */
@@ -203,6 +206,8 @@ export interface DocumentDatabaseOwner {
203
206
  grantInsight?(table: string): Promise<void>;
204
207
  /** Builds an index on a table that already holds rows without blocking its writes; never inside a transaction. */
205
208
  buildIndexConcurrently?(build: ConcurrentIndexBuild): Promise<void>;
209
+ /** Drops an index without blocking its table's reads and writes; never inside a transaction. */
210
+ dropIndexConcurrently?(name: string): Promise<void>;
206
211
  }
207
212
 
208
213
  export const QUERY_OPERATOR_KEYS = queryOperatorKeys;
@@ -275,6 +280,8 @@ export interface SqlDialect {
275
280
  orderTerm(expr: string, direction: 1 | -1): string;
276
281
  indexName(name: string): string;
277
282
  createIndex(props: CreateIndexProps): string;
283
+ /** Rows `{ name, valid }`, one per index on `table` besides its primary key. */
284
+ indexesOf(table: string): SqlFrag;
278
285
  applyUpdate(acc: string, op: DocumentUpdateOperator, path: string, value: unknown): SqlFrag;
279
286
  /** The stored document with these top-level fields replaced (value as JSON text) and these removed. */
280
287
  mergeDocument(set: [field: string, json: string][], removed: string[]): SqlFrag;
@@ -202,6 +202,9 @@ export class ServiceModel<
202
202
  [`removeOne${key}`]: async function (this: DatabaseService, ...args: any) {
203
203
  return this.__removeOne(queryOf(args));
204
204
  },
205
+ [`purge${key}`]: async function (this: DatabaseService, ...args: any) {
206
+ return this.__purgeMany(queryOf(args));
207
+ },
205
208
  [`update${key}`]: function (this: DatabaseService, ...args: any): UpdateChain {
206
209
  const query = queryOf(args);
207
210
  return { set: (update) => this.__updateMany(query, update) };
package/service/types.ts CHANGED
@@ -105,6 +105,7 @@ export type DatabaseService<
105
105
  __remove: (id: string) => Promise<Doc>;
106
106
  __removeMany: (query: _QueryOfDoc) => Promise<UpdateResult>;
107
107
  __removeOne: (query: _QueryOfDoc) => Promise<UpdateResult>;
108
+ __purgeMany: (query: _QueryOfDoc) => Promise<number>;
108
109
  __updateMany: (query: _QueryOfDoc, update: DocumentUpdateInput<Doc>) => Promise<UpdateResult>;
109
110
  __updateOne: (query: _QueryOfDoc, update: DocumentUpdateInput<Doc>) => Promise<UpdateResult>;
110
111
  __list(query?: _QueryOfDoc, queryOption?: _ListQueryOption): Promise<Doc[]>;
@@ -55,6 +55,8 @@ type QueryMethodOfKey<CapitalizedK extends string, Doc, Insight, _Args extends a
55
55
  [K in `remove${CapitalizedK}`]: (...args: _Args) => Promise<UpdateResult>;
56
56
  } & {
57
57
  [K in `removeOne${CapitalizedK}`]: (...args: _Args) => Promise<UpdateResult>;
58
+ } & {
59
+ [K in `purge${CapitalizedK}`]: (...args: _Args) => Promise<number>;
58
60
  } & {
59
61
  [K in `update${CapitalizedK}`]: (...args: _Args) => UpdateChain<Doc>;
60
62
  } & {
@@ -83,6 +85,7 @@ export type DatabaseInstanceWithQuerySort<T extends string = string, Input = any
83
85
  __remove: (id: string) => Promise<Doc>;
84
86
  __removeMany: (query: _QueryOfDoc) => Promise<UpdateResult>;
85
87
  __removeOne: (query: _QueryOfDoc) => Promise<UpdateResult>;
88
+ __purgeMany: (query: _QueryOfDoc) => Promise<number>;
86
89
  __updateMany: (query: _QueryOfDoc, update: DocumentUpdateInput<Doc>) => Promise<UpdateResult>;
87
90
  __updateOne: (query: _QueryOfDoc, update: DocumentUpdateInput<Doc>) => Promise<UpdateResult>;
88
91
  __list(query: _QueryOfDoc, queryOption?: _ListQueryOption): Promise<Doc[]>;
@@ -55,6 +55,8 @@ export type Mdl<Doc, Raw, _RawDoc = DocumentModel<Raw>, _RawQuery extends Docume
55
55
  updateMany(query: _RawQuery, update: DocumentUpdateInput<_RawDoc>, options?: DocumentScopeOptions): Promise<UpdateResult>;
56
56
  removeOne(query: _RawQuery): Promise<UpdateResult>;
57
57
  removeMany(query: _RawQuery): Promise<UpdateResult>;
58
+ /** Deletes every row the query matches, removed ones too, and answers how many: no hook, cascade or undo. */
59
+ purgeMany(query: _RawQuery): Promise<number>;
58
60
  updateById(id: string, update: DocumentUpdateInput<_RawDoc>, options?: DocumentUpdateOptions): Promise<UpdateResult>;
59
61
  removeById(id: string): Promise<UpdateResult>;
60
62
  /**
@@ -1,7 +1,7 @@
1
1
  import type { ENDPOINT_META, PromiseOrObject } from "akanjs/base";
2
2
  import type { FetchPolicy } from "akanjs/common";
3
3
  import type { EndpInfoArgs, EndpInfoClientReturns, EndpInfoNullable, EndpInfoReqType, EndpointCls, EndpointInfo, PromptMessage, SlceCnstFull, SlceCnstInsight, SlceCnstLight, SliceCls } from "akanjs/signal";
4
- type ExtendedEndpointReturn<ClientReturns, Full, Light, Insight> = ClientReturns extends (infer R)[] ? ExtendedEndpointReturn<R, Full, Light, Insight>[] : Full extends ClientReturns ? Full : Light extends ClientReturns ? Light : Insight extends ClientReturns ? Insight : ClientReturns;
4
+ type ExtendedEndpointReturn<ClientReturns, Full, Light, Insight> = unknown extends ClientReturns ? ClientReturns : ClientReturns extends (infer R)[] ? ExtendedEndpointReturn<R, Full, Light, Insight>[] : Full extends ClientReturns ? Full : Light extends ClientReturns ? Light : Insight extends ClientReturns ? Insight : ClientReturns;
5
5
  type EndpointClientReturns<E, SlceCls extends SliceCls | never> = [SlceCls] extends [never] ? EndpInfoClientReturns<E> : ExtendedEndpointReturn<EndpInfoClientReturns<E>, SlceCnstFull<SlceCls>, SlceCnstLight<SlceCls>, SlceCnstInsight<SlceCls>>;
6
6
  type OrNull<T, Nullable> = Nullable extends true ? T | null : T;
7
7
  type EndpInfoReturns<E, SlceCls extends SliceCls | never> = OrNull<EndpointClientReturns<E, SlceCls>, EndpInfoNullable<E>>;
@@ -40,6 +40,11 @@ export declare class SqliteDatabase extends SqliteDatabase_base implements Datab
40
40
  getStore(constant: ConstantModel, database: DatabaseModel, schema: SchemaOf): SqlDocumentStore;
41
41
  getMeta(key: string): string | undefined;
42
42
  setMeta(key: string, value: string): Promise<void>;
43
+ listMeta(prefix: string): {
44
+ key: string;
45
+ value: string;
46
+ }[];
47
+ deleteMeta(key: string): Promise<void>;
43
48
  transaction<T>(fn: () => PromiseOrObject<T>): Promise<T>;
44
49
  afterCommit(fn: () => PromiseOrObject<void>): Promise<void>;
45
50
  checkpoint(mode?: "PASSIVE" | "FULL" | "RESTART" | "TRUNCATE"): void;
@@ -71,10 +76,16 @@ export declare class PostgresDatabase extends PostgresDatabase_base implements D
71
76
  getStore(constant: ConstantModel, database: DatabaseModel, schema: SchemaOf): SqlDocumentStore;
72
77
  getMeta(key: string): Promise<string | undefined>;
73
78
  setMeta(key: string, value: string): Promise<void>;
79
+ listMeta(prefix: string): Promise<{
80
+ key: string;
81
+ value: string;
82
+ }[]>;
83
+ deleteMeta(key: string): Promise<void>;
74
84
  transaction<T>(fn: () => PromiseOrObject<T>): Promise<T>;
75
85
  afterCommit(fn: () => PromiseOrObject<void>): Promise<void>;
76
86
  lockSchema<T>(fn: () => Promise<T>): Promise<T>;
77
87
  hasTable(table: string): Promise<boolean>;
78
88
  hasValidIndex(name: string): Promise<boolean>;
79
89
  buildIndexConcurrently({ name, next, create, replace }: ConcurrentIndexBuild): Promise<void>;
90
+ dropIndexConcurrently(name: string): Promise<void>;
80
91
  }
@@ -50,6 +50,7 @@ export declare class SqlDocumentStore {
50
50
  modifiedCount: number;
51
51
  upsertedId: any;
52
52
  }>;
53
+ purgeManyByQuery(query: DocumentQuery): Promise<number>;
53
54
  private compiledUpdate;
54
55
  bulkWrite(operations: {
55
56
  updateOne: {
@@ -30,6 +30,7 @@ export declare class PostgresDialect implements SqlDialect {
30
30
  orderTerm(expr: string, direction: 1 | -1): string;
31
31
  indexName(name: string): string;
32
32
  createIndex({ name, table, unique, columns, concurrently, where }: CreateIndexProps): string;
33
+ indexesOf(table: string): SqlFrag;
33
34
  applyUpdate(acc: string, op: DocumentUpdateOperator, path: string, value: unknown): SqlFrag;
34
35
  mergeDocument(set: [field: string, json: string][], removed: string[]): SqlFrag;
35
36
  affectedRows(result: unknown): number;
@@ -25,6 +25,7 @@ export declare class SqliteDialect implements SqlDialect {
25
25
  orderTerm(expr: string, direction: 1 | -1): string;
26
26
  indexName(name: string): string;
27
27
  createIndex({ name, table, unique, columns, where }: CreateIndexProps): string;
28
+ indexesOf(table: string): SqlFrag;
28
29
  applyUpdate(acc: string, op: DocumentUpdateOperator, path: string, value: unknown): SqlFrag;
29
30
  mergeDocument(set: [field: string, json: string][], removed: string[]): SqlFrag;
30
31
  affectedRows(result: unknown): number;
@@ -68,6 +68,7 @@ export interface DocumentStore {
68
68
  modifiedCount: number;
69
69
  upsertedId: string | null;
70
70
  }>;
71
+ purgeManyByQuery(query: DocumentQuery): Promise<number>;
71
72
  bulkWrite(operations: {
72
73
  updateOne: {
73
74
  filter: DocumentQuery;
@@ -197,6 +198,14 @@ export interface DocumentDatabaseOwner {
197
198
  getSearchIndex(): SearchIndex | null;
198
199
  getMeta(key: string): Promise<string | undefined> | string | undefined;
199
200
  setMeta(key: string, value: string): Promise<void>;
201
+ listMeta(prefix: string): Promise<{
202
+ key: string;
203
+ value: string;
204
+ }[]> | {
205
+ key: string;
206
+ value: string;
207
+ }[];
208
+ deleteMeta(key: string): Promise<void>;
200
209
  afterCommit(fn: () => PromiseOrObject<void>): Promise<void>;
201
210
  transaction?<T>(fn: () => PromiseOrObject<T>): Promise<T>;
202
211
  /** Runs `fn` as the only schema change: Postgres races two `CREATE … IF NOT EXISTS` into a duplicate-key error. */
@@ -207,6 +216,8 @@ export interface DocumentDatabaseOwner {
207
216
  grantInsight?(table: string): Promise<void>;
208
217
  /** Builds an index on a table that already holds rows without blocking its writes; never inside a transaction. */
209
218
  buildIndexConcurrently?(build: ConcurrentIndexBuild): Promise<void>;
219
+ /** Drops an index without blocking its table's reads and writes; never inside a transaction. */
220
+ dropIndexConcurrently?(name: string): Promise<void>;
210
221
  }
211
222
  export declare const QUERY_OPERATOR_KEYS: Set<string>;
212
223
  export interface SqlFrag {
@@ -270,6 +281,8 @@ export interface SqlDialect {
270
281
  orderTerm(expr: string, direction: 1 | -1): string;
271
282
  indexName(name: string): string;
272
283
  createIndex(props: CreateIndexProps): string;
284
+ /** Rows `{ name, valid }`, one per index on `table` besides its primary key. */
285
+ indexesOf(table: string): SqlFrag;
273
286
  applyUpdate(acc: string, op: DocumentUpdateOperator, path: string, value: unknown): SqlFrag;
274
287
  /** The stored document with these top-level fields replaced (value as JSON text) and these removed. */
275
288
  mergeDocument(set: [field: string, json: string][], removed: string[]): SqlFrag;
@@ -33,6 +33,7 @@ export type DatabaseService<T extends string = string, Input = any, Doc = any, O
33
33
  __remove: (id: string) => Promise<Doc>;
34
34
  __removeMany: (query: _QueryOfDoc) => Promise<UpdateResult>;
35
35
  __removeOne: (query: _QueryOfDoc) => Promise<UpdateResult>;
36
+ __purgeMany: (query: _QueryOfDoc) => Promise<number>;
36
37
  __updateMany: (query: _QueryOfDoc, update: DocumentUpdateInput<Doc>) => Promise<UpdateResult>;
37
38
  __updateOne: (query: _QueryOfDoc, update: DocumentUpdateInput<Doc>) => Promise<UpdateResult>;
38
39
  __list(query?: _QueryOfDoc, queryOption?: _ListQueryOption): Promise<Doc[]>;
@@ -0,0 +1,4 @@
1
+ export declare class EditSeed {
2
+ #private;
3
+ static keyOf(edit: Record<string, unknown>, modelObjKey: string, viewAtKey: string): string;
4
+ }
@@ -14,6 +14,7 @@ import type { ModalSurfaceProps } from "../Dialog/modalSurface";
14
14
  import { Modal } from "../Modal";
15
15
  import DraftBar from "./DraftBar";
16
16
  import { type DraftProp, editDraftScope, newDraftScope } from "./draftScope";
17
+ import { EditSeed } from "./editSeed";
17
18
  import { NewFormOrigin } from "./newFormOrigin";
18
19
 
19
20
  const EDIT_PAYLOAD_MAX_AGE_MS = 60_000;
@@ -206,10 +207,15 @@ export default function EditModal<Full extends { id: string }>({
206
207
  Date.now() - viewAt.getTime() > EDIT_PAYLOAD_MAX_AGE_MS
207
208
  );
208
209
  }, []);
210
+ const filledSeedRef = useRef<string | null>(null);
209
211
  useEffect(() => {
210
212
  if (!modelEdit) return;
211
213
  const refName = (modelEdit as ServerEdit<string, Full>).refName;
212
214
  const editType: "edit" | "new" = refName && (modelEdit as DynamicRecord)[names.modelObj] ? "edit" : "new";
215
+ const seedKey = EditSeed.keyOf(modelEdit as DynamicRecord, names.modelObj, names.modelViewAt);
216
+
217
+ if (seedKey === filledSeedRef.current && (st.get() as unknown as DynamicRecord)[names.modelModal]) return;
218
+ filledSeedRef.current = seedKey;
213
219
  const cnst = ConstantRegistry.getDatabase(modelName);
214
220
  const modelRef = cnst.full;
215
221
  if (editType === "edit") {
@@ -0,0 +1,28 @@
1
+ import { deepObjectify } from "akanjs/common";
2
+
3
+ export class EditSeed {
4
+ static keyOf(edit: Record<string, unknown>, modelObjKey: string, viewAtKey: string): string {
5
+ const modelObj = edit[modelObjKey] as { id: string; updatedAt?: unknown } | undefined;
6
+ if (edit.refName && modelObj)
7
+
8
+ return `edit:${modelObj.id}@${EditSeed.#timeOf(modelObj.updatedAt) ?? EditSeed.#timeOf(edit[viewAtKey])}`;
9
+ return `new:${EditSeed.#stableStringify(deepObjectify(edit, { serializable: true, convertDate: "string" }))}`;
10
+ }
11
+
12
+ static #timeOf(value: unknown): number | null {
13
+ if (value === null || value === undefined) return null;
14
+ const time = new Date(value as string).getTime();
15
+ return Number.isNaN(time) ? null : time;
16
+ }
17
+
18
+ static #stableStringify(value: unknown): string {
19
+ if (value === null || typeof value !== "object") return JSON.stringify(value) ?? "null";
20
+ if (Array.isArray(value)) return `[${value.map((item) => EditSeed.#stableStringify(item)).join(",")}]`;
21
+ const source = value as Record<string, unknown>;
22
+ const entries = Object.keys(source)
23
+ .filter((key) => source[key] !== undefined)
24
+ .sort()
25
+ .map((key) => `${JSON.stringify(key)}:${EditSeed.#stableStringify(source[key])}`);
26
+ return `{${entries.join(",")}}`;
27
+ }
28
+ }
@@ -147,14 +147,12 @@ export interface PanelProps {
147
147
  }
148
148
  export const Panel = ({ className, menu, children, loading = "eager" }: PanelProps) => {
149
149
  const { menu: currentMenu } = useContext(TabContext);
150
- const [loaded, setLoaded] = useState(menu === currentMenu);
151
-
150
+ const isShown = currentMenu === menu;
151
+ const [loaded, setLoaded] = useState(isShown);
152
+ const isMounted = loading === "eager" || isShown || (loading === "lazy" && loaded);
152
153
  useEffect(() => {
153
- if (loading === "eager") setLoaded(true);
154
- else if (loading === "lazy" && !loaded && currentMenu === menu) setLoaded(true);
155
- else if (loading === "every") setLoaded(currentMenu === menu);
156
- }, [currentMenu]);
154
+ setLoaded(isMounted);
155
+ }, [isMounted]);
157
156
 
158
- if (loading === "eager") return <div className={cn(className, currentMenu !== menu && "hidden")}>{children}</div>;
159
- else return loaded ? <div className={cn(className, currentMenu !== menu && "hidden")}>{children}</div> : null;
157
+ return isMounted ? <div className={cn(className, !isShown && "hidden")}>{children}</div> : null;
160
158
  };