akanjs 3.0.0-alpha.71 → 3.0.0-alpha.73
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/client/cookie.ts +21 -34
- package/client/csrTypes.ts +0 -12
- package/client/frameConfig.ts +13 -0
- package/common/Logger.ts +13 -6
- package/common/TrustedProxy.ts +134 -0
- package/common/clamp.ts +2 -0
- package/common/dynamicRecord.ts +12 -0
- package/common/index.ts +5 -1
- package/common/isValidDate.ts +1 -1
- package/common/requestOrigin.ts +32 -0
- package/common/{httpClient.ts → restClient.ts} +16 -9
- package/constant/types.ts +22 -0
- package/dictionary/base.dictionary.ts +4 -0
- package/dictionary/trans.ts +24 -5
- package/document/dataLoader.ts +0 -4
- package/document/filterMeta.ts +2 -2
- package/document/loaderInfo.ts +4 -1
- package/fetch/client/fetchClient.ts +65 -88
- package/fetch/client/fetchHandle.ts +57 -0
- package/fetch/client/httpClient.ts +42 -14
- package/fetch/client/sliceInitHandle.ts +101 -0
- package/fetch/fetchType/appliedReturn.type.ts +64 -1
- package/fetch/fetchType/sliceFetch.type.ts +5 -5
- package/fetch/requestStorage.ts +19 -5
- package/index.ts +16 -2
- package/local/apps/serverLifecycle/serverLifecycle-local.db-shm +0 -0
- package/local/apps/serverLifecycle/serverLifecycle-local_solid.db-shm +0 -0
- package/package.json +1 -1
- package/server/akanApp.ts +3 -19
- package/server/akanAppHeaders.ts +10 -4
- package/server/akanOption.ts +14 -1
- package/server/akanServer.ts +3 -0
- package/server/cachePolicy.ts +2 -11
- package/server/decorators.ts +12 -3
- package/server/devtools/constantSerializer.ts +8 -1
- package/server/di/diLifecycle.ts +21 -1
- package/server/di/resolveHierarchy.ts +3 -3
- package/server/imageOptimizer.ts +2 -17
- package/server/mcp/McpDispatcher.ts +8 -2
- package/server/resolver/database.resolver.ts +13 -8
- package/server/resolver/signal.resolver.ts +3 -3
- package/server/routeTreeBuilder.ts +1 -15
- package/server/routing/apiRouter.ts +4 -5
- package/server/routing/appWsData.ts +9 -6
- package/server/staticPath.ts +34 -0
- package/server/webRouter.ts +35 -41
- package/service/adapt.ts +1 -5
- package/service/predefinedAdaptor/cache.adaptor.ts +8 -4
- package/service/predefinedAdaptor/database.adaptor.ts +37 -1766
- package/service/predefinedAdaptor/sql/PendingStoreEnsures.ts +22 -0
- package/service/predefinedAdaptor/sql/QueryCompiler.ts +225 -0
- package/service/predefinedAdaptor/sql/SqlDocumentStore.ts +812 -0
- package/service/predefinedAdaptor/sql/UpdateCompiler.ts +67 -0
- package/service/predefinedAdaptor/sql/dialect/postgres.ts +151 -0
- package/service/predefinedAdaptor/sql/dialect/sqlite.ts +134 -0
- package/service/predefinedAdaptor/sql/driver/bunSqlite.ts +29 -0
- package/service/predefinedAdaptor/sql/driver/libsql.ts +37 -0
- package/service/predefinedAdaptor/sql/driver/postgres.ts +42 -0
- package/service/predefinedAdaptor/sql/types.ts +251 -0
- package/service/predefinedAdaptor/sql/values.ts +91 -0
- package/signal/CrossSiteGuard.ts +71 -0
- package/signal/SignalFailure.ts +51 -0
- package/signal/endpoint.ts +2 -2
- package/signal/exception.ts +43 -6
- package/signal/index.ts +2 -0
- package/signal/intercept.ts +5 -2
- package/signal/internal.ts +10 -2
- package/signal/serializer/fetch.serializer.ts +11 -1
- package/signal/signalContext.ts +25 -28
- package/signal/slice.ts +1 -1
- package/store/SliceRequest.ts +23 -0
- package/store/action.ts +177 -114
- package/store/agent/AgentBridge.ts +3 -1
- package/store/agentic/StExposeBuilder.ts +7 -4
- package/store/agentic/StStateBuilder.ts +9 -4
- package/store/agentic/StToolBuilder.ts +21 -7
- package/store/store.ts +1 -1
- package/store/storeInstance.ts +3 -3
- package/store/storeRegistry.ts +1 -1
- package/types/client/cookie.d.ts +13 -2
- package/types/client/csrTypes.d.ts +0 -1
- package/types/client/frameConfig.d.ts +1 -0
- package/types/common/TrustedProxy.d.ts +24 -0
- package/types/common/clamp.d.ts +2 -0
- package/types/common/dynamicRecord.d.ts +14 -0
- package/types/common/index.d.ts +5 -1
- package/types/common/requestOrigin.d.ts +14 -0
- package/types/common/restClient.d.ts +27 -0
- package/types/constant/types.d.ts +13 -0
- package/types/dictionary/base.dictionary.d.ts +1 -1
- package/types/dictionary/dictionary.d.ts +8 -8
- package/types/document/dataLoader.d.ts +0 -3
- package/types/document/filterMeta.d.ts +2 -2
- package/types/document/loaderInfo.d.ts +2 -2
- package/types/fetch/client/fetchHandle.d.ts +24 -0
- package/types/fetch/client/httpClient.d.ts +2 -0
- package/types/fetch/client/sliceInitHandle.d.ts +29 -0
- package/types/fetch/fetchType/appliedReturn.type.d.ts +38 -1
- package/types/fetch/fetchType/sliceFetch.type.d.ts +5 -5
- package/types/fetch/requestStorage.d.ts +14 -0
- package/types/index.d.ts +17 -2
- package/types/server/akanOption.d.ts +8 -1
- package/types/server/decorators.d.ts +12 -2
- package/types/server/di/diLifecycle.d.ts +9 -1
- package/types/server/routeTreeBuilder.d.ts +1 -2
- package/types/server/routing/appWsData.d.ts +7 -3
- package/types/server/staticPath.d.ts +17 -0
- package/types/service/adapt.d.ts +1 -1
- package/types/service/predefinedAdaptor/database.adaptor.d.ts +15 -407
- package/types/service/predefinedAdaptor/sql/PendingStoreEnsures.d.ts +6 -0
- package/types/service/predefinedAdaptor/sql/QueryCompiler.d.ts +28 -0
- package/types/service/predefinedAdaptor/sql/SqlDocumentStore.d.ts +127 -0
- package/types/service/predefinedAdaptor/sql/UpdateCompiler.d.ts +17 -0
- package/types/service/predefinedAdaptor/sql/dialect/postgres.d.ts +27 -0
- package/types/service/predefinedAdaptor/sql/dialect/sqlite.d.ts +27 -0
- package/types/service/predefinedAdaptor/sql/driver/bunSqlite.d.ts +16 -0
- package/types/service/predefinedAdaptor/sql/driver/libsql.d.ts +17 -0
- package/types/service/predefinedAdaptor/sql/driver/postgres.d.ts +17 -0
- package/types/service/predefinedAdaptor/sql/types.d.ts +226 -0
- package/types/service/predefinedAdaptor/sql/values.d.ts +13 -0
- package/types/signal/CrossSiteGuard.d.ts +44 -0
- package/types/signal/SignalFailure.d.ts +28 -0
- package/types/signal/exception.d.ts +38 -54774
- package/types/signal/index.d.ts +2 -0
- package/types/signal/intercept.d.ts +3 -3
- package/types/signal/internal.d.ts +5 -1
- package/types/signal/serializer/fetch.serializer.d.ts +7 -1
- package/types/store/SliceRequest.d.ts +16 -0
- package/types/store/agentic/StToolBuilder.d.ts +10 -1
- package/types/ui/Agent/Menu.d.ts +1 -1
- package/types/ui/Field/Choice.d.ts +52 -0
- package/types/ui/Field/Collection.d.ts +51 -0
- package/types/ui/Field/DateField.d.ts +32 -0
- package/types/ui/Field/Label.d.ts +9 -0
- package/types/ui/Field/NumberField.d.ts +43 -0
- package/types/ui/Field/Relation.d.ts +80 -0
- package/types/ui/Field/Text.d.ts +101 -0
- package/types/ui/Field/index.d.ts +50 -0
- package/types/ui/Field.d.ts +1 -412
- package/types/ui/Load/Edit_Client.d.ts +3 -1
- package/types/ui/Load/Stream.d.ts +22 -0
- package/types/ui/Load/index.d.ts +2 -0
- package/types/ui/System/Root.d.ts +4 -0
- package/types/ui/index.d.ts +1 -1
- package/types/ui/recipe/badgeRecipe.d.ts +2 -2
- package/types/ui/recipe/buttonRecipe.d.ts +2 -2
- package/types/webkit/useCamera.d.ts +10 -1
- package/types/webkit/usePurchase.d.ts +4 -0
- package/ui/Agent/Menu.tsx +2 -2
- package/ui/Data/TableList.tsx +1 -1
- package/ui/DraggableList.tsx +1 -2
- package/ui/Field/Choice.tsx +172 -0
- package/ui/Field/Collection.tsx +293 -0
- package/ui/Field/DateField.tsx +138 -0
- package/ui/Field/Label.tsx +31 -0
- package/ui/Field/NumberField.tsx +169 -0
- package/ui/Field/Relation.tsx +387 -0
- package/ui/Field/Text.tsx +360 -0
- package/ui/Field/index.tsx +70 -0
- package/ui/Input.tsx +3 -3
- package/ui/Load/Edit.tsx +2 -2
- package/ui/Load/Edit_Client.tsx +11 -3
- package/ui/Load/Pagination.tsx +25 -25
- package/ui/Load/Stream.tsx +38 -0
- package/ui/Load/Units.tsx +50 -37
- package/ui/Load/View.tsx +25 -17
- package/ui/Load/index.ts +2 -0
- package/ui/Model/EditModal.tsx +7 -7
- package/ui/Signal/Message.tsx +7 -7
- package/ui/Signal/PubSub.tsx +4 -1
- package/ui/System/Client.tsx +0 -2
- package/ui/System/Gtag.tsx +1 -1
- package/ui/System/Root.tsx +4 -0
- package/ui/index.ts +1 -1
- package/ui/overlayPosition.ts +1 -2
- package/webkit/useCamera.tsx +22 -6
- package/webkit/useCodepush.tsx +2 -2
- package/webkit/useCsrValues.ts +1 -3
- package/webkit/useFetch.ts +4 -3
- package/webkit/useFrameRuntime.ts +14 -7
- package/webkit/usePurchase.tsx +4 -1
- package/fetch/serializer/fetch.serializer.ts +0 -142
- package/fetch/serializer/index.ts +0 -1
- package/types/common/httpClient.d.ts +0 -20
- package/types/fetch/serializer/fetch.serializer.d.ts +0 -14
- package/types/fetch/serializer/index.d.ts +0 -1
- package/types/fetch/serializer.d.ts +0 -1
- package/ui/Field.tsx +0 -1639
|
@@ -0,0 +1,812 @@
|
|
|
1
|
+
import { DEFAULT_VALUE, dayjs, FIELD_META } from "akanjs/base";
|
|
2
|
+
import { type ConstantModel, getDefault } from "akanjs/constant";
|
|
3
|
+
import {
|
|
4
|
+
createDocumentId,
|
|
5
|
+
type DatabaseModel,
|
|
6
|
+
type DocumentQuery,
|
|
7
|
+
type DocumentSchema,
|
|
8
|
+
type DocumentUpdate,
|
|
9
|
+
type DocumentUpdateInput,
|
|
10
|
+
type DocumentUpdateNode,
|
|
11
|
+
type DocumentUpdateOptions,
|
|
12
|
+
documentQueryHelper,
|
|
13
|
+
isDocumentId,
|
|
14
|
+
isDocumentUpdateNode,
|
|
15
|
+
NoDocumentError,
|
|
16
|
+
resolveDocumentUpdate,
|
|
17
|
+
sanitizeJson,
|
|
18
|
+
} from "akanjs/document";
|
|
19
|
+
import { descriptorHash, quoteIdent, stableJson } from "../sqlDescriptor";
|
|
20
|
+
import { SqliteDialect } from "./dialect/sqlite";
|
|
21
|
+
import { QueryCompiler } from "./QueryCompiler";
|
|
22
|
+
import {
|
|
23
|
+
type AkanSqlStatement,
|
|
24
|
+
BASE_COLUMNS,
|
|
25
|
+
type DocumentDatabaseOwner,
|
|
26
|
+
type DocumentRecord,
|
|
27
|
+
type FieldMap,
|
|
28
|
+
type FindManyOptions,
|
|
29
|
+
type FindOneOptions,
|
|
30
|
+
MODIFICATION_STATE,
|
|
31
|
+
type ModificationState,
|
|
32
|
+
type MutableDocumentRecord,
|
|
33
|
+
type ProjectedSqliteDocumentRow,
|
|
34
|
+
type ProjectionOption,
|
|
35
|
+
REF_NAME_RE,
|
|
36
|
+
RESERVED_RE,
|
|
37
|
+
type SearchJoin,
|
|
38
|
+
type SortOption,
|
|
39
|
+
type SqlDialect,
|
|
40
|
+
type SqliteDocumentRow,
|
|
41
|
+
toSafeRefName,
|
|
42
|
+
type WriteHookOptions,
|
|
43
|
+
} from "./types";
|
|
44
|
+
import { UpdateCompiler } from "./UpdateCompiler";
|
|
45
|
+
import { decodeDateValue, encodeSqlValue, jsonStr } from "./values";
|
|
46
|
+
|
|
47
|
+
export class SqlDocumentStore {
|
|
48
|
+
readonly schema: DocumentSchema;
|
|
49
|
+
readonly table: string;
|
|
50
|
+
readonly compiler: QueryCompiler;
|
|
51
|
+
readonly updateCompiler: UpdateCompiler;
|
|
52
|
+
#insertStmt: AkanSqlStatement | null = null;
|
|
53
|
+
#readStmtCache = new Map<string, AkanSqlStatement>();
|
|
54
|
+
#docPrototype: object | null = null;
|
|
55
|
+
#immutableKeys: string[] | null = null;
|
|
56
|
+
|
|
57
|
+
constructor(
|
|
58
|
+
private readonly owner: DocumentDatabaseOwner,
|
|
59
|
+
readonly constant: ConstantModel,
|
|
60
|
+
readonly database: DatabaseModel,
|
|
61
|
+
schema: DocumentSchema,
|
|
62
|
+
private readonly dialect: SqlDialect = new SqliteDialect(),
|
|
63
|
+
) {
|
|
64
|
+
this.schema = schema;
|
|
65
|
+
this.table = database.refName;
|
|
66
|
+
const fields = database.doc[FIELD_META] as unknown as FieldMap;
|
|
67
|
+
|
|
68
|
+
this.compiler = new QueryCompiler(fields, dialect, this.table, () => !!this.owner.getSearchIndex()?.enabled);
|
|
69
|
+
this.updateCompiler = new UpdateCompiler(fields, dialect);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async ensure() {
|
|
73
|
+
this.assertValidRefName(this.table);
|
|
74
|
+
const db = this.owner.getConnection();
|
|
75
|
+
const ts = this.dialect.timestampType();
|
|
76
|
+
await db.execute(
|
|
77
|
+
`CREATE TABLE IF NOT EXISTS ${quoteIdent(this.table)} (
|
|
78
|
+
"id" TEXT PRIMARY KEY NOT NULL,
|
|
79
|
+
"createdAt" ${ts} NOT NULL,
|
|
80
|
+
"updatedAt" ${ts} NOT NULL,
|
|
81
|
+
"removedAt" ${ts},
|
|
82
|
+
"_doc" ${this.dialect.docColumnType()} NOT NULL
|
|
83
|
+
)`,
|
|
84
|
+
);
|
|
85
|
+
await this.owner.setMeta(
|
|
86
|
+
`table:${this.table}`,
|
|
87
|
+
await descriptorHash({ table: this.table, columns: ["id", "createdAt", "updatedAt", "removedAt", "_doc"] }),
|
|
88
|
+
);
|
|
89
|
+
for (const [idx, index] of this.schema.indexes.entries()) {
|
|
90
|
+
const name = index.name ?? `${this.table}_${Object.keys(index.fields).map(toSafeRefName).join("_")}_${idx}`;
|
|
91
|
+
this.assertValidRefName(name);
|
|
92
|
+
const hash = await descriptorHash(index);
|
|
93
|
+
const metaKey = `index:${this.table}:${name}`;
|
|
94
|
+
const existing = await this.owner.getMeta(metaKey);
|
|
95
|
+
if (existing && existing !== hash) throw new Error(`Index descriptor mismatch: ${name}`);
|
|
96
|
+
const expressions = Object.keys(index.fields).map((field) => this.compiler.fieldExpr(field));
|
|
97
|
+
const unique = index.unique ? "UNIQUE " : "";
|
|
98
|
+
await db.execute(
|
|
99
|
+
`CREATE ${unique}INDEX IF NOT EXISTS ${quoteIdent(name)} ON ${quoteIdent(this.table)} (${expressions.join(", ")})`,
|
|
100
|
+
);
|
|
101
|
+
await this.owner.setMeta(metaKey, hash);
|
|
102
|
+
}
|
|
103
|
+
await this.owner.getSearchIndex()?.ensureRef(this.constant, this.database);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async create(data: DocumentRecord, { runSaveHooks = true }: WriteHookOptions = {}) {
|
|
107
|
+
const now = Date.now();
|
|
108
|
+
const id = data.id ?? createDocumentId(now);
|
|
109
|
+
if (!isDocumentId(id)) throw new Error(`Invalid ID value: ${id}`);
|
|
110
|
+
const doc = this.hydrate(
|
|
111
|
+
this.prepareDocument({
|
|
112
|
+
...data,
|
|
113
|
+
id,
|
|
114
|
+
createdAt: data.createdAt ?? dayjs(now),
|
|
115
|
+
updatedAt: data.updatedAt ?? dayjs(now),
|
|
116
|
+
}),
|
|
117
|
+
);
|
|
118
|
+
if (runSaveHooks) await this.runHooks("save", "create", doc, "pre");
|
|
119
|
+
await this.runHooks("create", "create", doc, "pre");
|
|
120
|
+
const row = this.toRow(doc);
|
|
121
|
+
await this.insertStmt().run(row.id, row.createdAt, row.updatedAt, row.removedAt, row._doc);
|
|
122
|
+
await this.runHooks("create", "create", doc, "post");
|
|
123
|
+
if (runSaveHooks) await this.runHooks("save", "create", doc, "post");
|
|
124
|
+
return doc;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async clone(data: DocumentRecord & { id: string }) {
|
|
128
|
+
return this.create(data);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async update(id: string, patch: DocumentRecord, options: WriteHookOptions = {}) {
|
|
132
|
+
const current = await this.pickByIdForWrite(id);
|
|
133
|
+
return await this.writeUpdatedDocument(id, { ...current, ...patch, id, updatedAt: dayjs() }, current, options);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async remove(id: string) {
|
|
137
|
+
|
|
138
|
+
return this.update(id, { removedAt: dayjs() }, { runSaveHooks: false, crudType: "remove" });
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async updateOneByQuery(query: DocumentQuery, update: DocumentUpdateInput, options: DocumentUpdateOptions = {}) {
|
|
142
|
+
const resolved = resolveDocumentUpdate(update);
|
|
143
|
+
const { assignments, params } = this.compiledUpdate(resolved);
|
|
144
|
+
const { where, params: whereParams } = this.writeQuery(query, "updateOneByQuery");
|
|
145
|
+
const subquery = `SELECT ${quoteIdent("id")} FROM ${quoteIdent(this.table)} WHERE ${where} ORDER BY ${this.compiler.orderBy()} LIMIT 1`;
|
|
146
|
+
const sql = `UPDATE ${quoteIdent(this.table)} SET ${assignments.join(", ")} WHERE ${quoteIdent("id")} IN (${subquery})`;
|
|
147
|
+
const changes = this.dialect.affectedRows(
|
|
148
|
+
await this.owner
|
|
149
|
+
.getConnection()
|
|
150
|
+
.prepare(sql)
|
|
151
|
+
.run(...params, ...whereParams),
|
|
152
|
+
);
|
|
153
|
+
if (changes > 0) return { acknowledged: true, matchedCount: 1, modifiedCount: 1, upsertedId: null };
|
|
154
|
+
if (!options.upsert) return { acknowledged: true, matchedCount: 0, modifiedCount: 0, upsertedId: null };
|
|
155
|
+
const inserted = await this.create(this.applyInsertUpdate(this.extractInsertBase(query), resolved), {
|
|
156
|
+
runSaveHooks: false,
|
|
157
|
+
});
|
|
158
|
+
return { acknowledged: true, matchedCount: 0, modifiedCount: 1, upsertedId: inserted.id };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async updateManyByQuery(query: DocumentQuery, update: DocumentUpdateInput) {
|
|
162
|
+
const { assignments, params } = this.compiledUpdate(resolveDocumentUpdate(update));
|
|
163
|
+
const { where, params: whereParams } = this.writeQuery(query, "updateManyByQuery");
|
|
164
|
+
const sql = `UPDATE ${quoteIdent(this.table)} SET ${assignments.join(", ")} WHERE ${where}`;
|
|
165
|
+
const changes = this.dialect.affectedRows(
|
|
166
|
+
await this.owner
|
|
167
|
+
.getConnection()
|
|
168
|
+
.prepare(sql)
|
|
169
|
+
.run(...params, ...whereParams),
|
|
170
|
+
);
|
|
171
|
+
return { acknowledged: true, matchedCount: changes, modifiedCount: changes };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async removeManyByQuery(query: DocumentQuery) {
|
|
175
|
+
|
|
176
|
+
return this.updateManyByQuery(query, { removedAt: dayjs() });
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async removeOneByQuery(query: DocumentQuery) {
|
|
180
|
+
|
|
181
|
+
return this.updateOneByQuery(query, { removedAt: dayjs() });
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
private compiledUpdate(update: DocumentUpdate) {
|
|
185
|
+
const compiled = this.updateCompiler.compile(update);
|
|
186
|
+
return {
|
|
187
|
+
assignments: [`${quoteIdent("updatedAt")} = ?`, ...compiled.assignments],
|
|
188
|
+
params: [Date.now(), ...compiled.params],
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async bulkWrite(
|
|
193
|
+
operations: { updateOne: { filter: DocumentQuery; update: DocumentUpdateInput; upsert?: boolean } }[],
|
|
194
|
+
) {
|
|
195
|
+
let matchedCount = 0;
|
|
196
|
+
let modifiedCount = 0;
|
|
197
|
+
let upsertedId: string | null = null;
|
|
198
|
+
for (const operation of operations) {
|
|
199
|
+
const result = await this.updateOneByQuery(operation.updateOne.filter, operation.updateOne.update, {
|
|
200
|
+
upsert: operation.updateOne.upsert,
|
|
201
|
+
});
|
|
202
|
+
matchedCount += result.matchedCount;
|
|
203
|
+
modifiedCount += result.modifiedCount;
|
|
204
|
+
upsertedId ??= result.upsertedId ?? null;
|
|
205
|
+
}
|
|
206
|
+
return { acknowledged: true, matchedCount, modifiedCount, upsertedId };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async find(query?: DocumentQuery, options: FindManyOptions = {}) {
|
|
210
|
+
const { where, params, joins } = this.safeQuery(query);
|
|
211
|
+
const limitValue = Number(options.limit ?? 0);
|
|
212
|
+
const skipValue = Number(options.skip ?? 0);
|
|
213
|
+
const limit = limitValue ? ` LIMIT ${limitValue}` : "";
|
|
214
|
+
const offset = skipValue ? ` OFFSET ${skipValue}` : "";
|
|
215
|
+
const join = this.joinSql(joins);
|
|
216
|
+
const order = options.sample ? "ORDER BY random()" : `ORDER BY ${this.orderBy(options.sort, joins)}`;
|
|
217
|
+
const args = [...this.joinParams(joins), ...params];
|
|
218
|
+
const projection = this.resolveProjection(options.select);
|
|
219
|
+
if (projection) {
|
|
220
|
+
const rows = await this.prepareReadStmt(
|
|
221
|
+
`SELECT ${this.projectionSql(projection)} FROM ${quoteIdent(this.table)}${join} WHERE ${where} ${order}${limit}${offset}`,
|
|
222
|
+
).all<ProjectedSqliteDocumentRow>(...args);
|
|
223
|
+
return rows.map((row) => this.hydrate(this.fromProjectedRow(row, projection), undefined, { track: false }));
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const star = joins.length ? `${quoteIdent(this.table)}.*` : "*";
|
|
227
|
+
const rows = await this.prepareReadStmt(
|
|
228
|
+
`SELECT ${star} FROM ${quoteIdent(this.table)}${join} WHERE ${where} ${order}${limit}${offset}`,
|
|
229
|
+
).all<SqliteDocumentRow>(...args);
|
|
230
|
+
return rows.map((row) => this.hydrate(this.fromRow(row), undefined, { track: false }));
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
async findIds(
|
|
234
|
+
query?: DocumentQuery,
|
|
235
|
+
options: { sort?: SortOption; skip?: number | null; limit?: number | null; sample?: number } = {},
|
|
236
|
+
) {
|
|
237
|
+
const { where, params, joins } = this.safeQuery(query);
|
|
238
|
+
const limitValue = Number(options.limit ?? 0);
|
|
239
|
+
const skipValue = Number(options.skip ?? 0);
|
|
240
|
+
const limit = limitValue ? ` LIMIT ${limitValue}` : "";
|
|
241
|
+
const offset = skipValue ? ` OFFSET ${skipValue}` : "";
|
|
242
|
+
const join = this.joinSql(joins);
|
|
243
|
+
const order = options.sample ? "ORDER BY random()" : `ORDER BY ${this.orderBy(options.sort, joins)}`;
|
|
244
|
+
const rows = await this.prepareReadStmt(
|
|
245
|
+
`SELECT ${quoteIdent(this.table)}."id" FROM ${quoteIdent(this.table)}${join} WHERE ${where} ${order}${limit}${offset}`,
|
|
246
|
+
).all<{ id: string }>(...this.joinParams(joins), ...params);
|
|
247
|
+
return rows.map((row) => row.id);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
async findOne(query?: DocumentQuery, options: FindOneOptions = {}) {
|
|
251
|
+
return (await this.find(query, { ...options, limit: 1, sample: options.sample ? 1 : undefined })).at(0) ?? null;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
async findId(query?: DocumentQuery, options: { sort?: SortOption; skip?: number | null; sample?: boolean } = {}) {
|
|
255
|
+
return (await this.findIds(query, { ...options, limit: 1, sample: options.sample ? 1 : undefined })).at(0) ?? null;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
async pickOne(query?: DocumentQuery, options: FindOneOptions = {}) {
|
|
259
|
+
const doc = await this.findOne(query, options);
|
|
260
|
+
if (!doc) throw new NoDocumentError(`No Document (${this.table}): ${JSON.stringify(query)}`);
|
|
261
|
+
return doc;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
async pickById(id: string) {
|
|
265
|
+
const doc = await this.findOne({ id } as DocumentQuery);
|
|
266
|
+
if (!doc) throw new NoDocumentError(`No Document (${this.table}): ${id}`);
|
|
267
|
+
return doc;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
async exists(query?: DocumentQuery) {
|
|
271
|
+
return this.findId(query);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
async count(query?: DocumentQuery) {
|
|
275
|
+
const { where, params, joins } = this.safeQuery(query);
|
|
276
|
+
const row = await this.prepareReadStmt(
|
|
277
|
+
`SELECT count(*) as count FROM ${quoteIdent(this.table)}${this.joinSql(joins)} WHERE ${where}`,
|
|
278
|
+
).get<{ count: number }>(...this.joinParams(joins), ...params);
|
|
279
|
+
return row?.count ?? 0;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
async insight(query?: DocumentQuery) {
|
|
283
|
+
const insightFields = this.constant.insight[FIELD_META] as unknown as FieldMap;
|
|
284
|
+
const result: DocumentRecord = {};
|
|
285
|
+
for (const [key, field] of Object.entries(insightFields)) {
|
|
286
|
+
const props = field.getProps();
|
|
287
|
+
if (!props.accumulate) {
|
|
288
|
+
result[key] = props.default;
|
|
289
|
+
} else if (
|
|
290
|
+
typeof props.accumulate === "object" &&
|
|
291
|
+
!Object.keys(props.accumulate as Record<string, unknown>).some((key) => key.startsWith("$"))
|
|
292
|
+
) {
|
|
293
|
+
result[key] = await this.count(documentQueryHelper.all(query ?? {}, props.accumulate as DocumentQuery));
|
|
294
|
+
} else {
|
|
295
|
+
result[key] = await this.count(query);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
return result;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
private safeQuery(query?: DocumentQuery) {
|
|
302
|
+
return this.compiler.compile(documentQueryHelper.all(documentQueryHelper.empty("removedAt"), query ?? {}));
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
private writeQuery(query: DocumentQuery | undefined, operation: string) {
|
|
306
|
+
const compiled = this.safeQuery(query);
|
|
307
|
+
if (compiled.joins.length)
|
|
308
|
+
throw new Error(`q.search() cannot be used in ${operation} on "${this.table}"; query-level writes take no join.`);
|
|
309
|
+
return compiled;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
private joinSql(joins: SearchJoin[]) {
|
|
313
|
+
return joins.length ? ` ${joins.map((join) => join.sql).join(" ")}` : "";
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
private joinParams(joins: SearchJoin[]) {
|
|
317
|
+
return joins.flatMap((join) => join.params);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
private orderBy(sort: SortOption, joins: SearchJoin[]) {
|
|
321
|
+
const explicit = sort && Object.keys(sort).length ? sort : null;
|
|
322
|
+
if (!explicit && joins.length) return `${joins[0].alias}."score", ${quoteIdent(this.table)}."id" DESC`;
|
|
323
|
+
return this.compiler.orderBy(explicit ?? undefined);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
private prepareDocument(data: DocumentRecord) {
|
|
327
|
+
const fields = this.database.doc[FIELD_META] as unknown as FieldMap;
|
|
328
|
+
const doc: MutableDocumentRecord = {};
|
|
329
|
+
for (const [key, field] of Object.entries(fields)) {
|
|
330
|
+
const props = field.getProps();
|
|
331
|
+
const value = data[key];
|
|
332
|
+
if (value === undefined) {
|
|
333
|
+
if (props.default !== undefined && props.default !== null) {
|
|
334
|
+
doc[key] = typeof props.default === "function" ? props.default(data) : props.default;
|
|
335
|
+
} else if (!props.nullable && !["removedAt"].includes(key)) {
|
|
336
|
+
if (["id", "createdAt", "updatedAt"].includes(key)) continue;
|
|
337
|
+
throw new Error(`Missing required field: ${key}`);
|
|
338
|
+
}
|
|
339
|
+
} else if (value === null && !props.nullable) {
|
|
340
|
+
throw new Error(`Field is not nullable: ${key}`);
|
|
341
|
+
} else {
|
|
342
|
+
doc[key] = value;
|
|
343
|
+
}
|
|
344
|
+
if (doc[key] !== undefined && doc[key] !== null) {
|
|
345
|
+
doc[key] = this.normalizeWriteValue(doc[key], props);
|
|
346
|
+
}
|
|
347
|
+
if (props.enum && doc[key] !== undefined && doc[key] !== null) {
|
|
348
|
+
const values = Array.isArray(doc[key]) ? doc[key] : [doc[key]];
|
|
349
|
+
const fieldEnum = props.enum as { has: (value: unknown) => boolean } | undefined;
|
|
350
|
+
const invalidValue = fieldEnum ? values.find((value: unknown) => !fieldEnum.has(value)) : undefined;
|
|
351
|
+
if (invalidValue !== undefined) throw new Error(`Invalid enum value for ${key}: ${invalidValue}`);
|
|
352
|
+
}
|
|
353
|
+
const validate = props.validate as ((value: unknown, doc: MutableDocumentRecord) => boolean) | undefined;
|
|
354
|
+
if (validate && doc[key] !== undefined && doc[key] !== null && !validate(doc[key], doc)) {
|
|
355
|
+
throw new Error(`Invalid field value: ${key}`);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
return { ...data, ...doc };
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
private extractInsertBase(query: DocumentQuery): Record<string, unknown> {
|
|
362
|
+
if (!query || typeof query !== "object" || Array.isArray(query) || "kind" in query) return {};
|
|
363
|
+
return Object.fromEntries(
|
|
364
|
+
Object.entries(query).flatMap(([key, value]) => {
|
|
365
|
+
if (["all", "any"].includes(key) || key.startsWith("$")) return [];
|
|
366
|
+
if (value === null || ["string", "number", "boolean"].includes(typeof value)) return [[key, value]];
|
|
367
|
+
return [];
|
|
368
|
+
}),
|
|
369
|
+
);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
private applyInsertUpdate(base: DocumentRecord, update: DocumentUpdate) {
|
|
373
|
+
const doc: MutableDocumentRecord = { ...base };
|
|
374
|
+
const setPath = (path: string, value: unknown) => {
|
|
375
|
+
const parts = path.split(".");
|
|
376
|
+
let target: MutableDocumentRecord = doc;
|
|
377
|
+
for (const part of parts.slice(0, -1)) {
|
|
378
|
+
target[part] ??= {};
|
|
379
|
+
target = target[part] as MutableDocumentRecord;
|
|
380
|
+
}
|
|
381
|
+
target[parts.at(-1) as string] = value;
|
|
382
|
+
};
|
|
383
|
+
const getPath = (path: string) =>
|
|
384
|
+
path.split(".").reduce<unknown>((obj, key) => (obj as DocumentRecord | undefined)?.[key], doc);
|
|
385
|
+
for (const [path, raw] of Object.entries(update)) {
|
|
386
|
+
if (raw === undefined) continue;
|
|
387
|
+
const node: DocumentUpdateNode = isDocumentUpdateNode(raw) ? raw : { kind: "update", op: "set", value: raw };
|
|
388
|
+
const current = getPath(path);
|
|
389
|
+
switch (node.op) {
|
|
390
|
+
case "set":
|
|
391
|
+
case "setOnInsert":
|
|
392
|
+
setPath(path, node.value);
|
|
393
|
+
break;
|
|
394
|
+
case "unset": {
|
|
395
|
+
const parts = path.split(".");
|
|
396
|
+
let target: MutableDocumentRecord | undefined = doc;
|
|
397
|
+
for (const part of parts.slice(0, -1)) {
|
|
398
|
+
target = target?.[part] as MutableDocumentRecord | undefined;
|
|
399
|
+
if (!target || typeof target !== "object") break;
|
|
400
|
+
}
|
|
401
|
+
if (target) delete target[parts.at(-1) as string];
|
|
402
|
+
break;
|
|
403
|
+
}
|
|
404
|
+
case "inc":
|
|
405
|
+
setPath(path, Number(current ?? 0) + Number(node.value));
|
|
406
|
+
break;
|
|
407
|
+
case "mul":
|
|
408
|
+
setPath(path, Number(current ?? 0) * Number(node.value));
|
|
409
|
+
break;
|
|
410
|
+
case "min":
|
|
411
|
+
setPath(path, current === undefined ? node.value : Math.min(Number(current), Number(node.value)));
|
|
412
|
+
break;
|
|
413
|
+
case "max":
|
|
414
|
+
setPath(path, current === undefined ? node.value : Math.max(Number(current), Number(node.value)));
|
|
415
|
+
break;
|
|
416
|
+
case "push":
|
|
417
|
+
setPath(path, [...(Array.isArray(current) ? current : []), node.value]);
|
|
418
|
+
break;
|
|
419
|
+
case "addToSet": {
|
|
420
|
+
const arr = Array.isArray(current) ? current : [];
|
|
421
|
+
if (!arr.some((item) => stableJson(item) === stableJson(node.value))) setPath(path, [...arr, node.value]);
|
|
422
|
+
break;
|
|
423
|
+
}
|
|
424
|
+
case "pull":
|
|
425
|
+
if (Array.isArray(current))
|
|
426
|
+
setPath(
|
|
427
|
+
path,
|
|
428
|
+
current.filter((item) => stableJson(item) !== stableJson(node.value)),
|
|
429
|
+
);
|
|
430
|
+
break;
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
return doc;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
private toRow(doc: DocumentRecord) {
|
|
437
|
+
const payload = { ...doc };
|
|
438
|
+
delete payload.id;
|
|
439
|
+
delete payload.createdAt;
|
|
440
|
+
delete payload.updatedAt;
|
|
441
|
+
delete payload.removedAt;
|
|
442
|
+
return {
|
|
443
|
+
id: doc.id,
|
|
444
|
+
createdAt: Number(encodeSqlValue(doc.createdAt ?? dayjs())),
|
|
445
|
+
updatedAt: Number(encodeSqlValue(doc.updatedAt ?? dayjs())),
|
|
446
|
+
removedAt: doc.removedAt ? Number(encodeSqlValue(doc.removedAt)) : null,
|
|
447
|
+
_doc: JSON.stringify(sanitizeJson(payload)),
|
|
448
|
+
};
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
private fromRow(row: SqliteDocumentRow) {
|
|
452
|
+
|
|
453
|
+
const rawDoc: unknown = row._doc;
|
|
454
|
+
const raw = typeof rawDoc === "string" ? JSON.parse(rawDoc) : (rawDoc as Record<string, unknown>);
|
|
455
|
+
const payload = this.decodeDocumentPayload(raw);
|
|
456
|
+
return {
|
|
457
|
+
id: row.id,
|
|
458
|
+
createdAt: dayjs(Number(row.createdAt)),
|
|
459
|
+
updatedAt: dayjs(Number(row.updatedAt)),
|
|
460
|
+
removedAt: row.removedAt ? dayjs(Number(row.removedAt)) : undefined,
|
|
461
|
+
...payload,
|
|
462
|
+
};
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
private normalizeProjection(select: ProjectionOption): string[] | null {
|
|
466
|
+
if (!select) return null;
|
|
467
|
+
const fields = Object.entries(select)
|
|
468
|
+
.filter(([, included]) => included)
|
|
469
|
+
.map(([field]) => field);
|
|
470
|
+
return [...new Set(fields.filter((field) => field !== "_doc"))];
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
private resolveProjection(select: ProjectionOption): string[] | null {
|
|
474
|
+
const projection = this.normalizeProjection(select);
|
|
475
|
+
if (projection !== null) return projection;
|
|
476
|
+
return this.defaultProjection();
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
private defaultProjection(): string[] | null {
|
|
480
|
+
const fields = this.database.doc[FIELD_META] as unknown as FieldMap;
|
|
481
|
+
const entries = Object.entries(fields).filter(([key]) => !BASE_COLUMNS.has(key));
|
|
482
|
+
if (!entries.some(([, field]) => field.getProps().select === false)) return null;
|
|
483
|
+
return entries.flatMap(([key, field]) => (field.getProps().select === false ? [] : [key]));
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
private projectionSql(fields: string[]) {
|
|
487
|
+
const jsonFields = fields.filter((field) => !BASE_COLUMNS.has(field));
|
|
488
|
+
const baseColumns = [...BASE_COLUMNS].map((field) => quoteIdent(field));
|
|
489
|
+
const jsonColumns = jsonFields.map(
|
|
490
|
+
(field, idx) => `${this.compiler.projectExpr(field)} AS ${quoteIdent(this.projectionAlias(idx))}`,
|
|
491
|
+
);
|
|
492
|
+
return [...baseColumns, ...jsonColumns].join(", ");
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
private projectionAlias(idx: number) {
|
|
496
|
+
return `__akan_projection_${idx}`;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
private fromProjectedRow(row: ProjectedSqliteDocumentRow, fields: string[]) {
|
|
500
|
+
const doc: DocumentRecord = {
|
|
501
|
+
id: row.id,
|
|
502
|
+
createdAt: dayjs(Number(row.createdAt)),
|
|
503
|
+
updatedAt: dayjs(Number(row.updatedAt)),
|
|
504
|
+
removedAt: row.removedAt ? dayjs(Number(row.removedAt)) : undefined,
|
|
505
|
+
};
|
|
506
|
+
const jsonFields = fields.filter((field) => !BASE_COLUMNS.has(field));
|
|
507
|
+
for (const [idx, field] of jsonFields.entries()) {
|
|
508
|
+
const value = this.dialect.decodeProjected(row[this.projectionAlias(idx)]);
|
|
509
|
+
const props = (this.database.doc[FIELD_META] as unknown as FieldMap)[field]?.getProps?.();
|
|
510
|
+
if (value === null && !props?.nullable) {
|
|
511
|
+
if (props?.default != null) {
|
|
512
|
+
doc[field] =
|
|
513
|
+
typeof props.default === "function" ? (props.default as (data: unknown) => unknown)(doc) : props.default;
|
|
514
|
+
} else {
|
|
515
|
+
doc[field] =
|
|
516
|
+
((props as Record<string, unknown>).modelRef as { [DEFAULT_VALUE]?: unknown })?.[DEFAULT_VALUE] ?? null;
|
|
517
|
+
}
|
|
518
|
+
} else {
|
|
519
|
+
doc[field] = props ? this.decodeFieldValue(value, props) : value;
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
return doc;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
private async findForWrite(query?: DocumentQuery, options: FindManyOptions = {}) {
|
|
526
|
+
const { where, params, joins } = this.safeQuery(query);
|
|
527
|
+
const limitValue = Number(options.limit ?? 0);
|
|
528
|
+
const skipValue = Number(options.skip ?? 0);
|
|
529
|
+
const limit = limitValue ? ` LIMIT ${limitValue}` : "";
|
|
530
|
+
const offset = skipValue ? ` OFFSET ${skipValue}` : "";
|
|
531
|
+
const join = this.joinSql(joins);
|
|
532
|
+
const order = options.sample ? "ORDER BY random()" : `ORDER BY ${this.orderBy(options.sort, joins)}`;
|
|
533
|
+
const star = joins.length ? `${quoteIdent(this.table)}.*` : "*";
|
|
534
|
+
const rows = await this.prepareReadStmt(
|
|
535
|
+
`SELECT ${star} FROM ${quoteIdent(this.table)}${join} WHERE ${where} ${order}${limit}${offset}`,
|
|
536
|
+
).all<SqliteDocumentRow>(...this.joinParams(joins), ...params);
|
|
537
|
+
return rows.map((row) => this.hydrate(this.fromRow(row)));
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
private async findOneForWrite(query?: DocumentQuery, options: FindOneOptions = {}) {
|
|
541
|
+
return (
|
|
542
|
+
(await this.findForWrite(query, { ...options, limit: 1, sample: options.sample ? 1 : undefined })).at(0) ?? null
|
|
543
|
+
);
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
private async pickByIdForWrite(id: string) {
|
|
547
|
+
const doc = await this.findOneForWrite({ id } as DocumentQuery);
|
|
548
|
+
if (!doc) throw new NoDocumentError(`No Document (${this.table}): ${id}`);
|
|
549
|
+
return doc;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
private async writeUpdatedDocument(
|
|
553
|
+
id: string,
|
|
554
|
+
data: DocumentRecord,
|
|
555
|
+
originalData: DocumentRecord,
|
|
556
|
+
{ runSaveHooks = true, crudType = "update" }: WriteHookOptions = {},
|
|
557
|
+
) {
|
|
558
|
+
const prepared = this.prepareDocument({ ...data, id, updatedAt: dayjs() });
|
|
559
|
+
this.#assertImmutableUnchanged(prepared, originalData);
|
|
560
|
+
const doc = this.hydrate(prepared, originalData);
|
|
561
|
+
if (runSaveHooks) await this.runHooks("save", crudType, doc, "pre");
|
|
562
|
+
await this.runHooks(crudType, crudType, doc, "pre");
|
|
563
|
+
const row = this.toRow(doc);
|
|
564
|
+
await this.owner
|
|
565
|
+
.getConnection()
|
|
566
|
+
.prepare(
|
|
567
|
+
`UPDATE ${quoteIdent(this.table)} SET "createdAt" = ?, "updatedAt" = ?, "removedAt" = ?, "_doc" = ${this.dialect.docValuePlaceholder()} WHERE "id" = ?`,
|
|
568
|
+
)
|
|
569
|
+
.run(row.createdAt, row.updatedAt, row.removedAt, row._doc, id);
|
|
570
|
+
await this.runHooks(crudType, crudType, doc, "post");
|
|
571
|
+
if (runSaveHooks) await this.runHooks("save", crudType, doc, "post");
|
|
572
|
+
return doc;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
#assertImmutableUnchanged(prepared: DocumentRecord, originalData: DocumentRecord) {
|
|
576
|
+
this.#immutableKeys ??= Object.entries(this.database.doc[FIELD_META] as unknown as FieldMap)
|
|
577
|
+
.filter(([, fieldMeta]) => fieldMeta.getProps().immutable)
|
|
578
|
+
.map(([key]) => key);
|
|
579
|
+
if (!this.#immutableKeys.length) return;
|
|
580
|
+
const changed = this.#immutableKeys.filter((key) => jsonStr(prepared[key]) !== jsonStr(originalData[key]));
|
|
581
|
+
if (!changed.length) return;
|
|
582
|
+
|
|
583
|
+
throw new Error(
|
|
584
|
+
`Cannot modify immutable field${changed.length > 1 ? "s" : ""} on ${this.table} (${String(prepared.id)}): ${changed.join(", ")}`,
|
|
585
|
+
);
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
private decodeDocumentPayload(payload: Record<string, unknown>) {
|
|
589
|
+
const fields = this.database.doc[FIELD_META] as unknown as FieldMap;
|
|
590
|
+
const result: Record<string, unknown> = {};
|
|
591
|
+
for (const [key, fieldMeta] of Object.entries(fields)) {
|
|
592
|
+
if (BASE_COLUMNS.has(key)) continue;
|
|
593
|
+
const props = fieldMeta.getProps();
|
|
594
|
+
const value = payload[key];
|
|
595
|
+
if (value === undefined) {
|
|
596
|
+
const def = props.default;
|
|
597
|
+
if (def != null) {
|
|
598
|
+
result[key] = typeof def === "function" ? (def as (data: unknown) => unknown)(payload) : def;
|
|
599
|
+
} else if (props.nullable) {
|
|
600
|
+
result[key] = null;
|
|
601
|
+
} else {
|
|
602
|
+
result[key] =
|
|
603
|
+
((props as Record<string, unknown>).modelRef as { [DEFAULT_VALUE]?: unknown })?.[DEFAULT_VALUE] ?? null;
|
|
604
|
+
}
|
|
605
|
+
} else {
|
|
606
|
+
result[key] = this.decodeFieldValue(value, props);
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
for (const [key, value] of Object.entries(payload)) {
|
|
610
|
+
if (key in result || BASE_COLUMNS.has(key)) continue;
|
|
611
|
+
const props = fields[key]?.getProps?.();
|
|
612
|
+
result[key] = props ? this.decodeFieldValue(value, props) : value;
|
|
613
|
+
}
|
|
614
|
+
return result;
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
private decodeFieldValue(value: unknown, props: Record<string, unknown>): unknown {
|
|
618
|
+
if (value === undefined || value === null) return value;
|
|
619
|
+
if (props.isMap) {
|
|
620
|
+
const entries = value instanceof Map ? [...value.entries()] : Object.entries(value as Record<string, unknown>);
|
|
621
|
+
return new Map(entries.map(([key, item]) => [key, this.decodeMapValue(item, props)]));
|
|
622
|
+
}
|
|
623
|
+
if (props.modelRef === Date) {
|
|
624
|
+
if (Array.isArray(value)) return value.map((item) => (item === null ? item : decodeDateValue(item)));
|
|
625
|
+
return decodeDateValue(value);
|
|
626
|
+
}
|
|
627
|
+
if (Array.isArray(value)) return value.map((item) => this.decodeNestedValue(item, props));
|
|
628
|
+
return this.decodeNestedValue(value, props);
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
private decodeMapValue(value: unknown, props: Record<string, unknown>) {
|
|
632
|
+
if (value === undefined || value === null) return value;
|
|
633
|
+
if (props.of === Date) return decodeDateValue(value);
|
|
634
|
+
return value;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
private decodeNestedValue(value: unknown, props: Record<string, unknown>): unknown {
|
|
638
|
+
if (!value || typeof value !== "object") return value;
|
|
639
|
+
if (!props.isClass || !props.isScalar) return value;
|
|
640
|
+
const scalarFields = (props.modelRef as { [FIELD_META]?: FieldMap } | undefined)?.[FIELD_META];
|
|
641
|
+
if (!scalarFields) return value;
|
|
642
|
+
const source = value as Record<string, unknown>;
|
|
643
|
+
const defaults = getDefault(scalarFields as never) as Record<string, unknown>;
|
|
644
|
+
const result: Record<string, unknown> = {};
|
|
645
|
+
for (const [key, fieldMeta] of Object.entries(scalarFields)) {
|
|
646
|
+
const nestedProps = fieldMeta.getProps();
|
|
647
|
+
const nested = source[key];
|
|
648
|
+
result[key] = nested === undefined ? defaults[key] : this.decodeFieldValue(nested, nestedProps);
|
|
649
|
+
}
|
|
650
|
+
for (const [key, nested] of Object.entries(source)) {
|
|
651
|
+
if (!(key in result)) result[key] = nested;
|
|
652
|
+
}
|
|
653
|
+
return result;
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
private normalizeWriteValue(value: unknown, props: Record<string, unknown>): unknown {
|
|
657
|
+
if (value === undefined || value === null) return value;
|
|
658
|
+
if (props.modelRef === Date) {
|
|
659
|
+
if (Array.isArray(value))
|
|
660
|
+
return value.map((item) => (item === null || item === undefined ? item : dayjs(item as never)));
|
|
661
|
+
return dayjs(value as never);
|
|
662
|
+
}
|
|
663
|
+
if (!props.isClass || !props.isScalar) return value;
|
|
664
|
+
if (Array.isArray(value)) return value.map((item) => this.fillScalarDefaults(item, props));
|
|
665
|
+
return this.fillScalarDefaults(value, props);
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
private fillScalarDefaults(value: unknown, props: Record<string, unknown>): unknown {
|
|
669
|
+
if (!value || typeof value !== "object") return value;
|
|
670
|
+
const scalarFields = (props.modelRef as { [FIELD_META]?: FieldMap } | undefined)?.[FIELD_META];
|
|
671
|
+
if (!scalarFields) return value;
|
|
672
|
+
const defaults = getDefault(scalarFields as never) as Record<string, unknown>;
|
|
673
|
+
const result = { ...(value as Record<string, unknown>) };
|
|
674
|
+
for (const [key, fieldMeta] of Object.entries(scalarFields)) {
|
|
675
|
+
const nestedProps = fieldMeta.getProps();
|
|
676
|
+
if (result[key] === undefined) result[key] = defaults[key];
|
|
677
|
+
else result[key] = this.normalizeWriteValue(result[key], nestedProps);
|
|
678
|
+
}
|
|
679
|
+
return result;
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
/**
|
|
683
|
+
* `track` buys `isModified()` and costs a deep clone of the row, so it is decided per call site rather than
|
|
684
|
+
* paid everywhere. Only the read paths (`find`, and the projected read behind it) opt out — that clone was 42%
|
|
685
|
+
* of a list query, and a listed document is never the one a save hook runs on. Everything else keeps it, so an
|
|
686
|
+
* external caller of `hydrate` sees no change.
|
|
687
|
+
*/
|
|
688
|
+
hydrate(data: DocumentRecord, originalData: DocumentRecord = data, { track = true }: { track?: boolean } = {}) {
|
|
689
|
+
const isNew = !originalData.id;
|
|
690
|
+
const hydratedData = isNew ? this.prepareDocument(data) : data;
|
|
691
|
+
const doc = Object.assign(Object.create(this.#documentPrototype()), hydratedData);
|
|
692
|
+
if (!track) return doc;
|
|
693
|
+
Object.defineProperty(doc, MODIFICATION_STATE, {
|
|
694
|
+
value: {
|
|
695
|
+
isNew,
|
|
696
|
+
|
|
697
|
+
original: JSON.parse(JSON.stringify(sanitizeJson(originalData) ?? {})) as Record<string, unknown>,
|
|
698
|
+
} satisfies ModificationState,
|
|
699
|
+
});
|
|
700
|
+
return doc;
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
/**
|
|
704
|
+
* One prototype per store instead of six closures per document. It extends the model's own document prototype,
|
|
705
|
+
* so declared chain methods and `instanceof` are unaffected, and every method here is non-enumerable exactly as
|
|
706
|
+
* the previous per-document `defineProperties` made them.
|
|
707
|
+
*/
|
|
708
|
+
#documentPrototype() {
|
|
709
|
+
if (this.#docPrototype) return this.#docPrototype;
|
|
710
|
+
const store = this;
|
|
711
|
+
this.#docPrototype = Object.create(this.database.doc.prototype, {
|
|
712
|
+
set: {
|
|
713
|
+
value(this: DocumentRecord, patch: DocumentRecord) {
|
|
714
|
+
Object.assign(this, patch);
|
|
715
|
+
return this;
|
|
716
|
+
},
|
|
717
|
+
},
|
|
718
|
+
save: {
|
|
719
|
+
async value(this: DocumentRecord) {
|
|
720
|
+
return this.id ? store.update(this.id as string, this) : store.create(this);
|
|
721
|
+
},
|
|
722
|
+
},
|
|
723
|
+
refresh: {
|
|
724
|
+
async value(this: DocumentRecord) {
|
|
725
|
+
Object.assign(this, await store.pickById(this.id as string));
|
|
726
|
+
return this;
|
|
727
|
+
},
|
|
728
|
+
},
|
|
729
|
+
isModified: {
|
|
730
|
+
value(this: DocumentRecord & { [MODIFICATION_STATE]?: ModificationState }, field?: string) {
|
|
731
|
+
const state = this[MODIFICATION_STATE];
|
|
732
|
+
if (!state) throw new Error(SqlDocumentStore.#untrackedModificationMessage(store.table));
|
|
733
|
+
if (state.isNew) return true;
|
|
734
|
+
if (!field) return JSON.stringify(sanitizeJson(this)) !== JSON.stringify(state.original);
|
|
735
|
+
return JSON.stringify(sanitizeJson(this[field])) !== JSON.stringify(state.original[field]);
|
|
736
|
+
},
|
|
737
|
+
},
|
|
738
|
+
toJSON: {
|
|
739
|
+
value(this: DocumentRecord) {
|
|
740
|
+
return sanitizeJson(this);
|
|
741
|
+
},
|
|
742
|
+
},
|
|
743
|
+
toObject: {
|
|
744
|
+
value(this: DocumentRecord) {
|
|
745
|
+
return sanitizeJson(this);
|
|
746
|
+
},
|
|
747
|
+
},
|
|
748
|
+
}) as object;
|
|
749
|
+
return this.#docPrototype;
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
static #untrackedModificationMessage(table: string) {
|
|
753
|
+
return (
|
|
754
|
+
`isModified() is unavailable on this ${table} document: it was loaded through a read query, which does not ` +
|
|
755
|
+
`snapshot the row. Call it inside a save hook, or re-load the document through the write path (\`save()\`, ` +
|
|
756
|
+
`\`update()\`) before comparing.`
|
|
757
|
+
);
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
private async runHooks(
|
|
761
|
+
saveType: "save" | "create" | "update" | "remove",
|
|
762
|
+
crudType: "create" | "update" | "remove",
|
|
763
|
+
doc: DocumentRecord,
|
|
764
|
+
phase: "pre" | "post",
|
|
765
|
+
) {
|
|
766
|
+
const hooks = phase === "pre" ? this.schema.preHooks.get(saveType) : this.schema.postHooks.get(saveType);
|
|
767
|
+
for (const hook of hooks ?? []) {
|
|
768
|
+
const run = () => hook.call(doc, () => undefined, crudType);
|
|
769
|
+
if (phase === "post") await this.owner.afterCommit(run);
|
|
770
|
+
else await run();
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
private insertStmt() {
|
|
775
|
+
this.#insertStmt ??= this.owner
|
|
776
|
+
.getConnection()
|
|
777
|
+
.prepare(
|
|
778
|
+
`INSERT INTO ${quoteIdent(this.table)} ("id", "createdAt", "updatedAt", "removedAt", "_doc") VALUES (?, ?, ?, ?, ${this.dialect.docValuePlaceholder()})`,
|
|
779
|
+
);
|
|
780
|
+
return this.#insertStmt;
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
private prepareReadStmt(sql: string) {
|
|
784
|
+
const cached = this.#readStmtCache.get(sql);
|
|
785
|
+
if (cached) return cached;
|
|
786
|
+
|
|
787
|
+
if (this.#readStmtCache.size >= 128) {
|
|
788
|
+
const oldest = this.#readStmtCache.keys().next().value;
|
|
789
|
+
if (oldest) this.#readStmtCache.delete(oldest);
|
|
790
|
+
}
|
|
791
|
+
const stmt = this.owner.getConnection().prepare(sql);
|
|
792
|
+
this.#readStmtCache.set(sql, stmt);
|
|
793
|
+
return stmt;
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
private assertValidRefName(refName: string) {
|
|
797
|
+
if (!REF_NAME_RE.test(refName) || RESERVED_RE.test(refName))
|
|
798
|
+
throw new Error(`Invalid database identifier: ${refName}`);
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
/**
|
|
803
|
+
* The schema setups `getStore` starts but nobody awaits.
|
|
804
|
+
*
|
|
805
|
+
* `getStore` returns a store synchronously while `ensure()` goes on creating tables and indexes, so
|
|
806
|
+
* the connection could be closed out from under one — a server shutting down, or a test tearing its
|
|
807
|
+
* fixture down. That surfaced as an unhandled `Cannot use a closed database` blamed on whatever ran
|
|
808
|
+
* next, which made it look like a flaky test rather than a race at shutdown.
|
|
809
|
+
*
|
|
810
|
+
* Every statement `ensure()` runs is `IF NOT EXISTS`, so one cut short is simply redone on the next
|
|
811
|
+
* boot; a failure *before* the close is still a real problem and still surfaces.
|
|
812
|
+
*/
|