akanjs 3.1.0-rc.25 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "akanjs",
3
- "version": "3.1.0-rc.25",
3
+ "version": "3.1.0-rc.26",
4
4
  "sourceType": "module",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -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);
@@ -115,8 +121,8 @@ export class SqlDocumentStore {
115
121
 
116
122
  async #ensure() {
117
123
  this.assertValidRefName(this.table);
118
- const declared = await this.#declaredIndexes();
119
- 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);
120
126
  const createSchema = async () => {
121
127
  const db = this.owner.getConnection();
122
128
  const existed = !!this.owner.buildIndexConcurrently && !!(await this.owner.hasTable?.(this.table));
@@ -135,9 +141,10 @@ export class SqlDocumentStore {
135
141
  `table:${this.table}`,
136
142
  await descriptorHash({ table: this.table, columns: ["id", "createdAt", "updatedAt", "removedAt", "_doc"] }),
137
143
  );
138
- for (const index of declared) if (index.unbuilt) await this.#skipUnbuiltIndex(index, indexes);
144
+ const { adopted, stale } = await this.#reconcileIndexes(indexes);
139
145
  const concurrent: (DeclaredIndex & { replace: boolean })[] = [];
140
146
  for (const index of indexes) {
147
+ if (adopted.has(index.name)) continue;
141
148
  const stored = await this.owner.getMeta(index.metaKey);
142
149
  const replace = !!stored && stored !== index.hash;
143
150
  if (replace)
@@ -156,51 +163,141 @@ export class SqlDocumentStore {
156
163
  else await db.execute(index.create(index.name, false));
157
164
  await this.owner.setMeta(index.metaKey, index.hash);
158
165
  }
159
- 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 };
160
173
  };
161
- 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();
162
177
  for (const { name, next, create, replace, metaKey, hash } of concurrent) {
163
178
  await this.owner.buildIndexConcurrently?.({ name, next, create: (target) => create(target, true), replace });
164
179
  await this.owner.setMeta(metaKey, hash);
165
180
  }
181
+ for (const index of drops) await this.#dropStaleIndex(index);
166
182
  await this.owner.getSearchIndex()?.ensureRef(this.constant, this.database);
167
183
  }
168
184
 
185
+ static #builds(index: DocumentIndexDescriptor) {
186
+ return Object.values(index.fields).every((order) => order === 1 || order === -1);
187
+ }
188
+
169
189
  async #declaredIndexes(): Promise<DeclaredIndex[]> {
170
- return await Promise.all(
171
- this.schema.indexes.map(async (index, idx) => {
172
- const declared = index.name ?? `${this.table}_${Object.keys(index.fields).map(toSafeRefName).join("_")}_${idx}`;
173
- this.assertValidRefName(declared);
174
- const name = this.dialect.indexName(declared);
175
- const unbuilt = Object.values(index.fields).some((order) => order !== 1 && order !== -1);
176
- const columns = unbuilt
177
- ? []
178
- : Object.keys(index.fields).map((path) => ({
179
- path,
180
- expr: this.compiler.fieldExpr(path),
181
- isArray: this.compiler.isArrayPath(path),
182
- }));
183
- 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);
184
212
  return {
185
- name,
186
- next: this.dialect.indexName(`${declared}_next`),
187
- metaKey: `index:${this.table}:${name}`,
213
+ named: !!descriptor.name,
214
+ base,
215
+ definition,
188
216
 
189
- hash: await descriptorHash(predicate ? { ...index, predicate } : index),
190
- fields: index.fields,
191
- unbuilt,
192
- create: (target: string, concurrently: boolean) =>
193
- this.dialect.createIndex({
194
- name: target,
195
- table: this.table,
196
- unique: !!index.unique,
197
- columns,
198
- concurrently,
199
- where: predicate,
200
- }),
217
+ hash: await descriptorHash(predicate ? { ...descriptor, predicate } : descriptor),
218
+ suffix: (await descriptorHash(definition)).slice(0, 8),
219
+ create,
201
220
  };
202
221
  }),
203
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
+ }
204
301
  }
205
302
 
206
303
  #indexPredicate(where: DocumentQuery, name: string) {
@@ -210,20 +307,13 @@ export class SqlDocumentStore {
210
307
  return sql === "1 = 1" ? undefined : inlineParams(sql, params);
211
308
  }
212
309
 
213
- async #skipUnbuiltIndex(index: DeclaredIndex, built: DeclaredIndex[]) {
214
- const dropped =
215
- (await this.owner.getMeta(index.metaKey)) === index.hash && !built.some(({ name }) => name === index.name);
216
- if (dropped) {
217
- await this.owner.getConnection().execute(`DROP INDEX IF EXISTS ${quoteIdent(index.name)}`);
218
-
219
- await this.owner.setMeta(index.metaKey, "");
220
- }
310
+ #warnUnbuilt(index: DocumentIndexDescriptor) {
221
311
  const paths = Object.keys(index.fields);
222
312
  SqlDocumentStore.#logger.warn(
223
313
  `Index ${JSON.stringify(index.fields)} on ${this.table} is not built: only 1 and -1 order a key, and "text" is ` +
224
314
  `MongoDB's text index, which as a B-tree would copy each whole value and fail long writes on Postgres. Search ` +
225
315
  `${paths.join(", ")} through the full-text role, field(String, { text: "desc" }), or declare ` +
226
- `{ ${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.`,
227
317
  );
228
318
  }
229
319
 
@@ -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
 
@@ -194,6 +194,8 @@ export interface DocumentDatabaseOwner {
194
194
  getSearchIndex(): SearchIndex | null;
195
195
  getMeta(key: string): Promise<string | undefined> | string | undefined;
196
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>;
197
199
  afterCommit(fn: () => PromiseOrObject<void>): Promise<void>;
198
200
  transaction?<T>(fn: () => PromiseOrObject<T>): Promise<T>;
199
201
  /** Runs `fn` as the only schema change: Postgres races two `CREATE … IF NOT EXISTS` into a duplicate-key error. */
@@ -204,6 +206,8 @@ export interface DocumentDatabaseOwner {
204
206
  grantInsight?(table: string): Promise<void>;
205
207
  /** Builds an index on a table that already holds rows without blocking its writes; never inside a transaction. */
206
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>;
207
211
  }
208
212
 
209
213
  export const QUERY_OPERATOR_KEYS = queryOperatorKeys;
@@ -276,6 +280,8 @@ export interface SqlDialect {
276
280
  orderTerm(expr: string, direction: 1 | -1): string;
277
281
  indexName(name: string): string;
278
282
  createIndex(props: CreateIndexProps): string;
283
+ /** Rows `{ name, valid }`, one per index on `table` besides its primary key. */
284
+ indexesOf(table: string): SqlFrag;
279
285
  applyUpdate(acc: string, op: DocumentUpdateOperator, path: string, value: unknown): SqlFrag;
280
286
  /** The stored document with these top-level fields replaced (value as JSON text) and these removed. */
281
287
  mergeDocument(set: [field: string, json: string][], removed: string[]): SqlFrag;
@@ -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
  }
@@ -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;
@@ -198,6 +198,14 @@ export interface DocumentDatabaseOwner {
198
198
  getSearchIndex(): SearchIndex | null;
199
199
  getMeta(key: string): Promise<string | undefined> | string | undefined;
200
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>;
201
209
  afterCommit(fn: () => PromiseOrObject<void>): Promise<void>;
202
210
  transaction?<T>(fn: () => PromiseOrObject<T>): Promise<T>;
203
211
  /** Runs `fn` as the only schema change: Postgres races two `CREATE … IF NOT EXISTS` into a duplicate-key error. */
@@ -208,6 +216,8 @@ export interface DocumentDatabaseOwner {
208
216
  grantInsight?(table: string): Promise<void>;
209
217
  /** Builds an index on a table that already holds rows without blocking its writes; never inside a transaction. */
210
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>;
211
221
  }
212
222
  export declare const QUERY_OPERATOR_KEYS: Set<string>;
213
223
  export interface SqlFrag {
@@ -271,6 +281,8 @@ export interface SqlDialect {
271
281
  orderTerm(expr: string, direction: 1 | -1): string;
272
282
  indexName(name: string): string;
273
283
  createIndex(props: CreateIndexProps): string;
284
+ /** Rows `{ name, valid }`, one per index on `table` besides its primary key. */
285
+ indexesOf(table: string): SqlFrag;
274
286
  applyUpdate(acc: string, op: DocumentUpdateOperator, path: string, value: unknown): SqlFrag;
275
287
  /** The stored document with these top-level fields replaced (value as JSON text) and these removed. */
276
288
  mergeDocument(set: [field: string, json: string][], removed: string[]): SqlFrag;
@@ -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
  };