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.
- package/document/database.ts +3 -0
- package/document/into.ts +2 -0
- package/fetch/fetchType/endpointFetch.type.ts +11 -9
- package/package.json +1 -1
- package/server/resolver/database.resolver.ts +4 -0
- package/server/resolver/service.resolver.ts +3 -0
- package/service/predefinedAdaptor/database.adaptor.ts +57 -25
- package/service/predefinedAdaptor/sql/SqlDocumentStore.ts +174 -46
- package/service/predefinedAdaptor/sql/dialect/postgres.ts +7 -0
- package/service/predefinedAdaptor/sql/dialect/sqlite.ts +7 -0
- package/service/predefinedAdaptor/sql/types.ts +7 -0
- package/service/serviceModule.ts +3 -0
- package/service/types.ts +1 -0
- package/types/document/database.d.ts +3 -0
- package/types/document/into.d.ts +2 -0
- package/types/fetch/fetchType/endpointFetch.type.d.ts +1 -1
- package/types/service/predefinedAdaptor/database.adaptor.d.ts +11 -0
- package/types/service/predefinedAdaptor/sql/SqlDocumentStore.d.ts +1 -0
- 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 +13 -0
- package/types/service/types.d.ts +1 -0
- package/types/ui/Model/editSeed.d.ts +4 -0
- package/ui/Model/EditModal.tsx +6 -0
- package/ui/Model/editSeed.ts +28 -0
- package/ui/Tab/Provider.tsx +6 -8
package/document/database.ts
CHANGED
|
@@ -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> =
|
|
18
|
-
?
|
|
19
|
-
:
|
|
20
|
-
? Full
|
|
21
|
-
:
|
|
22
|
-
?
|
|
23
|
-
:
|
|
24
|
-
?
|
|
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
|
@@ -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
|
-
|
|
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
|
-
|
|
70
|
-
|
|
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
|
|
118
|
-
const indexes
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
170
|
-
this.schema.indexes.map(async (
|
|
171
|
-
const
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
const columns =
|
|
176
|
-
|
|
177
|
-
:
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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
|
-
|
|
186
|
-
|
|
213
|
+
named: !!descriptor.name,
|
|
214
|
+
base,
|
|
215
|
+
definition,
|
|
187
216
|
|
|
188
|
-
hash: await descriptorHash(predicate ? { ...
|
|
189
|
-
|
|
190
|
-
|
|
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
|
-
|
|
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
|
|
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;
|
package/service/serviceModule.ts
CHANGED
|
@@ -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[]>;
|
package/types/document/into.d.ts
CHANGED
|
@@ -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
|
}
|
|
@@ -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;
|
package/types/service/types.d.ts
CHANGED
|
@@ -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[]>;
|
package/ui/Model/EditModal.tsx
CHANGED
|
@@ -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
|
+
}
|
package/ui/Tab/Provider.tsx
CHANGED
|
@@ -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
|
|
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
|
-
|
|
154
|
-
|
|
155
|
-
else if (loading === "every") setLoaded(currentMenu === menu);
|
|
156
|
-
}, [currentMenu]);
|
|
154
|
+
setLoaded(isMounted);
|
|
155
|
+
}, [isMounted]);
|
|
157
156
|
|
|
158
|
-
|
|
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
|
};
|