inibase 3.1.1 → 3.3.0
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/README.md +84 -4
- package/dist/expression.d.ts +9 -0
- package/dist/expression.js +18 -5
- package/dist/file.d.ts +16 -0
- package/dist/file.js +151 -25
- package/dist/index.d.ts +37 -2
- package/dist/index.js +508 -106
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -798,6 +798,20 @@ await db.sum("user", "age");
|
|
|
798
798
|
|
|
799
799
|
// get the sum of column "age" by criteria (where "isActive" is equal to "false") in "user" table
|
|
800
800
|
await db.sum("user", ["age", ...], { isActive: false });
|
|
801
|
+
|
|
802
|
+
// aggregate over the elements of an array-of-objects column: `items.quantity`
|
|
803
|
+
// counts every element of every row's `items` (single array path per call;
|
|
804
|
+
// `product` here is an *element* predicate, matching elements whose linked
|
|
805
|
+
// product is the given row)
|
|
806
|
+
await db.sum("orders", "items.quantity", { product: "id-of-widget" }, { nested: true });
|
|
807
|
+
|
|
808
|
+
// multiple columns share one predicate and return a record
|
|
809
|
+
await db.sum(
|
|
810
|
+
"orders",
|
|
811
|
+
["items.quantity", "items.lineTotal"],
|
|
812
|
+
{ product: "id-of-widget" },
|
|
813
|
+
{ nested: true },
|
|
814
|
+
); // => { "items.quantity": 5, "items.lineTotal": 1295 }
|
|
801
815
|
```
|
|
802
816
|
|
|
803
817
|
</blockquote>
|
|
@@ -910,6 +924,68 @@ const posted = await db.post(
|
|
|
910
924
|
// posted.totalCentsLive === 847 (2*price(widget) + 1*price(gadget))
|
|
911
925
|
```
|
|
912
926
|
|
|
927
|
+
A child of an array-of-objects column can itself be computed. It is evaluated
|
|
928
|
+
once **per element** and stored in its own element cell, so it works like any
|
|
929
|
+
other element child in queries — including `sum` with `{ nested: true }`:
|
|
930
|
+
|
|
931
|
+
```ts
|
|
932
|
+
// ids are assigned per table in schema order: status=1, items=2, product=3,
|
|
933
|
+
// quantity=4, lineTotal=5
|
|
934
|
+
await db.createTable("orders", [
|
|
935
|
+
{ key: "status", type: "number" },
|
|
936
|
+
{
|
|
937
|
+
key: "items",
|
|
938
|
+
type: "array",
|
|
939
|
+
children: [
|
|
940
|
+
{ key: "product", type: "table", table: "product" },
|
|
941
|
+
{ key: "quantity", type: "number" },
|
|
942
|
+
// lineTotal = quantity × product.price ("4 * 3.2": id 4 = quantity,
|
|
943
|
+
// hop to the linked product's price, id 2 in `product`)
|
|
944
|
+
{ key: "lineTotal", type: "number", computed: "4 * 3.2" },
|
|
945
|
+
],
|
|
946
|
+
},
|
|
947
|
+
]);
|
|
948
|
+
|
|
949
|
+
const posted = await db.post(
|
|
950
|
+
"orders",
|
|
951
|
+
{
|
|
952
|
+
status: 1,
|
|
953
|
+
items: [
|
|
954
|
+
{ product: "id-of-widget", quantity: 2 },
|
|
955
|
+
{ product: "id-of-gadget", quantity: 1 },
|
|
956
|
+
],
|
|
957
|
+
},
|
|
958
|
+
undefined,
|
|
959
|
+
true,
|
|
960
|
+
);
|
|
961
|
+
// posted.items[0].lineTotal === 2 * price(widget)
|
|
962
|
+
// posted.items[1].lineTotal === 1 * price(gadget)
|
|
963
|
+
|
|
964
|
+
// per-product revenue without touching the line items yourself:
|
|
965
|
+
await db.sum("orders", "items.lineTotal", { product: "id-of-widget" }, { nested: true });
|
|
966
|
+
```
|
|
967
|
+
|
|
968
|
+
A top-level computed may read a computed child through a helper
|
|
969
|
+
(`{ key: "totalCents", type: "number", computed: "sum(5)" }`) — the children
|
|
970
|
+
are derived first, in dependency order.
|
|
971
|
+
|
|
972
|
+
**Element expressions (child computeds).**
|
|
973
|
+
|
|
974
|
+
- Every reference must be a sibling child of the same array root — top-level
|
|
975
|
+
columns and siblings of a *different* array are rejected
|
|
976
|
+
(`COMPUTED_FIELD_INVALID_TARGET`), and helpers (`sum`/`avg`/...) are not
|
|
977
|
+
allowed inside element expressions.
|
|
978
|
+
- Link hops are supported (`4 * 3.2` = sibling `quantity` × linked
|
|
979
|
+
`product.price`).
|
|
980
|
+
- The element field must be `type: "number"`.
|
|
981
|
+
- A **missing/null** operand in an element evaluates to `0` for that element
|
|
982
|
+
(it never aborts the write and never stores null). A genuinely dangling link
|
|
983
|
+
(a link to a missing row) still raises `COMPUTED_FIELD_DANGLING_LINK`, same
|
|
984
|
+
as top-level computeds.
|
|
985
|
+
- `updateTable` backfills element cells when a child computed is added or its
|
|
986
|
+
expression changes; the backfill resolves links from the persisted element
|
|
987
|
+
values and follows the same missing-operand → `0` rule.
|
|
988
|
+
|
|
913
989
|
**Expression language (v1, integer-only).**
|
|
914
990
|
|
|
915
991
|
- Operators: `+` `-` `*` `/` `%`; `( )` for grouping. Multiplication
|
|
@@ -941,10 +1017,14 @@ const posted = await db.post(
|
|
|
941
1017
|
`avg`/`min`/`max` over no values throw `COMPUTED_FIELD_ARITHMETIC`.
|
|
942
1018
|
- Non-numeric operands, division/modulo by zero, and arithmetic over missing
|
|
943
1019
|
(null) values throw `COMPUTED_FIELD_ARITHMETIC`; a link to a missing row
|
|
944
|
-
throws `COMPUTED_FIELD_DANGLING_LINK`.
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
1020
|
+
throws `COMPUTED_FIELD_DANGLING_LINK`. (Element expressions are the one
|
|
1021
|
+
exception: a missing/null operand there evaluates that element to `0` — see
|
|
1022
|
+
above.)
|
|
1023
|
+
- Child computeds (see above) extend the v1 rules to element fields of
|
|
1024
|
+
array-of-objects columns; array contents stay reachable from top-level
|
|
1025
|
+
helpers. Only one level of nesting is computed.
|
|
1026
|
+
- Missing link values make a single bare path evaluate to null (stored
|
|
1027
|
+
empty).
|
|
948
1028
|
|
|
949
1029
|
</blockquote>
|
|
950
1030
|
</details>
|
package/dist/expression.d.ts
CHANGED
|
@@ -118,6 +118,15 @@ export interface ResolveContext {
|
|
|
118
118
|
index: Map<number, FieldRef>;
|
|
119
119
|
/** Fetch (and cache) the id index of another table, or undefined. */
|
|
120
120
|
getTableIndex: (tableName: string) => Promise<Map<number, FieldRef> | undefined>;
|
|
121
|
+
/**
|
|
122
|
+
* When set, the expression being compiled belongs to an *element* of the
|
|
123
|
+
* array-of-objects field with this id (a child computed field). Every
|
|
124
|
+
* referenced field must be a sibling child of that same array; top-level
|
|
125
|
+
* fields and helpers are rejected.
|
|
126
|
+
*/
|
|
127
|
+
elementContext?: {
|
|
128
|
+
arrayRootId: number;
|
|
129
|
+
} | null;
|
|
121
130
|
}
|
|
122
131
|
/**
|
|
123
132
|
* Resolve a raw expression against the schema (and linked tables), returning
|
package/dist/expression.js
CHANGED
|
@@ -267,14 +267,27 @@ async function resolvePathNode(node, ctx, inHelper) {
|
|
|
267
267
|
ids[0],
|
|
268
268
|
]);
|
|
269
269
|
const arrayFieldId = hop0.arrayAncestor?.id ?? null;
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
270
|
+
if (ctx.elementContext) {
|
|
271
|
+
// Child computed field: every reference must be a sibling child of the
|
|
272
|
+
// computed field's own array root (helpers that recurse into other
|
|
273
|
+
// arrays and reads of top-level columns are invalid here).
|
|
274
|
+
if (hop0.arrayAncestor?.id !== ctx.elementContext.arrayRootId)
|
|
275
|
+
throw createError(ctx.language, "COMPUTED_FIELD_INVALID_TARGET", [
|
|
276
|
+
ctx.ownKey,
|
|
277
|
+
ids[0],
|
|
278
|
+
]);
|
|
279
|
+
if (hop0.nestedInArrayOfArrays)
|
|
280
|
+
throw createError(ctx.language, "COMPUTED_FIELD_INVALID_TARGET", [
|
|
281
|
+
ctx.ownKey,
|
|
282
|
+
ids[0],
|
|
283
|
+
]);
|
|
284
|
+
}
|
|
285
|
+
else if (!inHelper && arrayFieldId !== null)
|
|
273
286
|
throw createError(ctx.language, "COMPUTED_FIELD_INVALID_TARGET", [
|
|
274
287
|
ctx.ownKey,
|
|
275
288
|
ids[0],
|
|
276
289
|
]);
|
|
277
|
-
if (inHelper && hop0.arrayAncestor !== null && hop0.nestedInArrayOfArrays)
|
|
290
|
+
else if (inHelper && hop0.arrayAncestor !== null && hop0.nestedInArrayOfArrays)
|
|
278
291
|
throw createError(ctx.language, "COMPUTED_FIELD_INVALID_TARGET", [
|
|
279
292
|
ctx.ownKey,
|
|
280
293
|
ids[0],
|
|
@@ -332,7 +345,7 @@ async function resolveNode(node, ctx, inHelper) {
|
|
|
332
345
|
};
|
|
333
346
|
}
|
|
334
347
|
case "fn": {
|
|
335
|
-
if (inHelper)
|
|
348
|
+
if (inHelper || ctx.elementContext)
|
|
336
349
|
throw createError(ctx.language, "COMPUTED_FIELD_INVALID_TARGET", [
|
|
337
350
|
ctx.ownKey,
|
|
338
351
|
]);
|
package/dist/file.d.ts
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
import { type ComparisonOperator, type Field } from "./index.js";
|
|
2
2
|
export declare const DURABLE: boolean;
|
|
3
|
+
/**
|
|
4
|
+
* Run `fn` with a per-logical-task lock-owner store on its AsyncLocalStorage
|
|
5
|
+
* context. Every await continuation below `fn` then shares that store, so
|
|
6
|
+
* nested/reentrant acquisitions (put/delete id->line recursion, journal
|
|
7
|
+
* recovery re-locks, transaction flows) see the SAME store the outermost
|
|
8
|
+
* acquisition recorded as owner.
|
|
9
|
+
*
|
|
10
|
+
* Do NOT replace this with an enterWith() from inside lock(): that store is
|
|
11
|
+
* created in lock()'s own frame and (a) never propagates back to the caller's
|
|
12
|
+
* await continuation — every recursion then waits on its OWN lock until the
|
|
13
|
+
* watchdog (LOCK_WAIT_TIMEOUT) — and (b) can leak into sibling tasks, which
|
|
14
|
+
* then pass the writer lock as "reentrant" and re-race the Sep-2026 commit
|
|
15
|
+
* interleave (ENOENT renames, duplicate ids). `run()` gives clean per-op
|
|
16
|
+
* isolation and correct same-chain propagation.
|
|
17
|
+
*/
|
|
18
|
+
export declare const runWithLockStore: <T>(fn: () => T) => T;
|
|
3
19
|
export declare const lock: (folderPath: string, prefix?: string, ttl?: number) => Promise<void>;
|
|
4
20
|
/**
|
|
5
21
|
* Non-blocking lock acquisition (used by the read path and the open-time
|
package/dist/file.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
1
2
|
import { access, appendFile, copyFile, constants as fsConstants, open, readFile, stat, unlink, } from "node:fs/promises";
|
|
2
3
|
import { hostname } from "node:os";
|
|
3
4
|
import { join, resolve } from "node:path";
|
|
@@ -31,6 +32,87 @@ const LOCK_RETRY_MS = 13;
|
|
|
31
32
|
// multiple times (e.g. post() -> get() -> sort-cache). Depth 1 is a real
|
|
32
33
|
// filesystem acquisition; deeper acquisitions just bump the counter.
|
|
33
34
|
const lockDepths = new Map();
|
|
35
|
+
// In-process serialization: sibling tasks (e.g. two concurrent HTTP requests
|
|
36
|
+
// posting to the same table) must QUEUE behind the holder instead of passing
|
|
37
|
+
// straight through the depth counter. That bypass is exactly what let
|
|
38
|
+
// concurrent writers share the fixed `.tmp/<column>` staging path and race the
|
|
39
|
+
// pagination rename (diverged column files, duplicate ids, lost rows in the
|
|
40
|
+
// Sep-2026 pteam incident). Reentrancy from the *same* logical task (post() ->
|
|
41
|
+
// get() -> ensureTableRecovered re-lock, transaction flows) still bumps the
|
|
42
|
+
// depth without touching the filesystem, proven by the AsyncLocalStorage
|
|
43
|
+
// context recorded at acquisition time.
|
|
44
|
+
const lockReleaseWaiters = new Map();
|
|
45
|
+
const lockOwners = new Map();
|
|
46
|
+
// Upper bound on in-process wait time: converts a wedged or undetectable
|
|
47
|
+
// holder into a visible error instead of a silent hang. 0 disables it.
|
|
48
|
+
const LOCK_WAIT_TIMEOUT_MS = Number(process.env.INIBASE_LOCK_WAIT_TIMEOUT_MS ?? 120_000);
|
|
49
|
+
const lockContext = new AsyncLocalStorage();
|
|
50
|
+
// Resolve once the holder releases so the waiter can re-race for the lock.
|
|
51
|
+
const waitForLockRelease = (resolvedPath) => new Promise((resolveWaiter, rejectWaiter) => {
|
|
52
|
+
let waiters = lockReleaseWaiters.get(resolvedPath);
|
|
53
|
+
if (!waiters) {
|
|
54
|
+
waiters = new Set();
|
|
55
|
+
lockReleaseWaiters.set(resolvedPath, waiters);
|
|
56
|
+
}
|
|
57
|
+
let timer = null;
|
|
58
|
+
const wake = (error) => {
|
|
59
|
+
waiters.delete(wake);
|
|
60
|
+
if (timer)
|
|
61
|
+
clearTimeout(timer);
|
|
62
|
+
error ? rejectWaiter(error) : resolveWaiter();
|
|
63
|
+
};
|
|
64
|
+
waiters.add(wake);
|
|
65
|
+
if (LOCK_WAIT_TIMEOUT_MS > 0)
|
|
66
|
+
timer = setTimeout(() => wake(new Error(`LOCK_WAIT_TIMEOUT: ${resolvedPath}`)), LOCK_WAIT_TIMEOUT_MS);
|
|
67
|
+
});
|
|
68
|
+
// The AsyncLocalStorage store of this task chain (created on first acquire).
|
|
69
|
+
const currentLockStore = () => {
|
|
70
|
+
let store = lockContext.getStore();
|
|
71
|
+
if (!store) {
|
|
72
|
+
store = new Set();
|
|
73
|
+
lockContext.enterWith(store);
|
|
74
|
+
}
|
|
75
|
+
return store;
|
|
76
|
+
};
|
|
77
|
+
/**
|
|
78
|
+
* Run `fn` with a per-logical-task lock-owner store on its AsyncLocalStorage
|
|
79
|
+
* context. Every await continuation below `fn` then shares that store, so
|
|
80
|
+
* nested/reentrant acquisitions (put/delete id->line recursion, journal
|
|
81
|
+
* recovery re-locks, transaction flows) see the SAME store the outermost
|
|
82
|
+
* acquisition recorded as owner.
|
|
83
|
+
*
|
|
84
|
+
* Do NOT replace this with an enterWith() from inside lock(): that store is
|
|
85
|
+
* created in lock()'s own frame and (a) never propagates back to the caller's
|
|
86
|
+
* await continuation — every recursion then waits on its OWN lock until the
|
|
87
|
+
* watchdog (LOCK_WAIT_TIMEOUT) — and (b) can leak into sibling tasks, which
|
|
88
|
+
* then pass the writer lock as "reentrant" and re-race the Sep-2026 commit
|
|
89
|
+
* interleave (ENOENT renames, duplicate ids). `run()` gives clean per-op
|
|
90
|
+
* isolation and correct same-chain propagation.
|
|
91
|
+
*/
|
|
92
|
+
export const runWithLockStore = (fn) => {
|
|
93
|
+
if (lockContext.getStore())
|
|
94
|
+
return fn();
|
|
95
|
+
const store = new Set();
|
|
96
|
+
return lockContext.run(store, fn);
|
|
97
|
+
};
|
|
98
|
+
// True when this task already owns the lock (depth > 0 and the recorded owner
|
|
99
|
+
// context is this task's context). Bumps the depth and returns.
|
|
100
|
+
const isReentrant = (resolvedPath) => {
|
|
101
|
+
if (!lockDepths.get(resolvedPath))
|
|
102
|
+
return false;
|
|
103
|
+
if (lockOwners.get(resolvedPath) !== lockContext.getStore())
|
|
104
|
+
return false;
|
|
105
|
+
lockDepths.set(resolvedPath, lockDepths.get(resolvedPath) + 1);
|
|
106
|
+
return true;
|
|
107
|
+
};
|
|
108
|
+
const notifyLockReleased = (resolvedPath) => {
|
|
109
|
+
const waiters = lockReleaseWaiters.get(resolvedPath);
|
|
110
|
+
if (!waiters)
|
|
111
|
+
return;
|
|
112
|
+
lockReleaseWaiters.delete(resolvedPath);
|
|
113
|
+
for (const wake of waiters)
|
|
114
|
+
wake();
|
|
115
|
+
};
|
|
34
116
|
const lockFilePathFor = (folderPath, prefix) => join(folderPath, `${prefix ?? ""}.locked`);
|
|
35
117
|
const lockOwnerState = async (lockFilePath) => {
|
|
36
118
|
try {
|
|
@@ -67,12 +149,16 @@ const stealableLock = async (lockFilePath, mtimeMs, ttl) => {
|
|
|
67
149
|
export const lock = async (folderPath, prefix, ttl = DEFAULT_LOCK_TTL_MS) => {
|
|
68
150
|
const lockFilePath = lockFilePathFor(folderPath, prefix);
|
|
69
151
|
const resolvedPath = resolve(lockFilePath);
|
|
70
|
-
const depth = lockDepths.get(resolvedPath);
|
|
71
|
-
if (depth) {
|
|
72
|
-
lockDepths.set(resolvedPath, depth + 1);
|
|
73
|
-
return;
|
|
74
|
-
}
|
|
75
152
|
for (;;) {
|
|
153
|
+
// Same logical task already holds it -> reentrant depth bump.
|
|
154
|
+
if (isReentrant(resolvedPath))
|
|
155
|
+
return;
|
|
156
|
+
// A different in-process task holds it -> wait for the release, then
|
|
157
|
+
// re-race for the filesystem lock like any process.
|
|
158
|
+
if (lockDepths.get(resolvedPath)) {
|
|
159
|
+
await waitForLockRelease(resolvedPath);
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
76
162
|
try {
|
|
77
163
|
const lockFile = await open(lockFilePath, "wx");
|
|
78
164
|
try {
|
|
@@ -100,6 +186,7 @@ export const lock = async (folderPath, prefix, ttl = DEFAULT_LOCK_TTL_MS) => {
|
|
|
100
186
|
}
|
|
101
187
|
}
|
|
102
188
|
lockDepths.set(resolvedPath, 1);
|
|
189
|
+
lockOwners.set(resolvedPath, currentLockStore());
|
|
103
190
|
return;
|
|
104
191
|
}
|
|
105
192
|
catch (error) {
|
|
@@ -129,11 +216,10 @@ export const lock = async (folderPath, prefix, ttl = DEFAULT_LOCK_TTL_MS) => {
|
|
|
129
216
|
export const tryLock = async (folderPath, prefix, ttl = DEFAULT_LOCK_TTL_MS) => {
|
|
130
217
|
const lockFilePath = lockFilePathFor(folderPath, prefix);
|
|
131
218
|
const resolvedPath = resolve(lockFilePath);
|
|
132
|
-
|
|
133
|
-
if (depth) {
|
|
134
|
-
lockDepths.set(resolvedPath, depth + 1);
|
|
219
|
+
if (isReentrant(resolvedPath))
|
|
135
220
|
return true;
|
|
136
|
-
|
|
221
|
+
if (lockDepths.get(resolvedPath))
|
|
222
|
+
return false; // held by another in-process task: non-blocking
|
|
137
223
|
for (let attempt = 0; attempt < 2; attempt++) {
|
|
138
224
|
try {
|
|
139
225
|
const lockFile = await open(lockFilePath, "wx");
|
|
@@ -157,6 +243,7 @@ export const tryLock = async (folderPath, prefix, ttl = DEFAULT_LOCK_TTL_MS) =>
|
|
|
157
243
|
}
|
|
158
244
|
}
|
|
159
245
|
lockDepths.set(resolvedPath, 1);
|
|
246
|
+
lockOwners.set(resolvedPath, currentLockStore());
|
|
160
247
|
return true;
|
|
161
248
|
}
|
|
162
249
|
catch (error) {
|
|
@@ -183,12 +270,15 @@ export const unlock = async (folderPath, prefix) => {
|
|
|
183
270
|
return;
|
|
184
271
|
}
|
|
185
272
|
lockDepths.delete(resolvedPath);
|
|
273
|
+
lockOwners.delete(resolvedPath);
|
|
186
274
|
try {
|
|
187
275
|
await unlink(lockFilePath);
|
|
188
276
|
}
|
|
189
277
|
catch {
|
|
190
278
|
// Already released (stolen by a foreign-host stealer or cleaned up).
|
|
191
279
|
}
|
|
280
|
+
// Wake in-process waiters so they re-race for the released lock.
|
|
281
|
+
notifyLockReleased(resolvedPath);
|
|
192
282
|
};
|
|
193
283
|
export const write = async (filePath, data) => {
|
|
194
284
|
const handle = await open(filePath, "w");
|
|
@@ -242,6 +332,17 @@ export function escapeShellPath(filePath) {
|
|
|
242
332
|
// Escape double quotes and special shell characters
|
|
243
333
|
return `"${resolvedPath.replace(/(["\\$`])/g, "\\$1")}"`;
|
|
244
334
|
}
|
|
335
|
+
/**
|
|
336
|
+
* Unique-per-operation staging path: concurrent writers must never share the
|
|
337
|
+
* `.tmp/<column>` file. The fixed name let sibling operations clobber each
|
|
338
|
+
* other's in-progress temp and publish a temp that only contained one writer's
|
|
339
|
+
* content (column files following different winners -> diverged line counts).
|
|
340
|
+
* A pid + per-process counter suffix keeps every temp private; each pair is
|
|
341
|
+
* carried through the rename list and the journal, so commit/rollback/recovery
|
|
342
|
+
* still know exactly which files to move.
|
|
343
|
+
*/
|
|
344
|
+
let tempSeq = 0;
|
|
345
|
+
const tempPathFor = (filePath) => filePath.replace(/([^/]+)\/?$/, `.tmp/$1.${process.pid}-${(tempSeq++).toString(36)}`);
|
|
245
346
|
const _pipeline = async (filePath, rl, writeStream, transform) => {
|
|
246
347
|
if (filePath.endsWith(".gz"))
|
|
247
348
|
await pipeline(rl, transform, createGzip(), writeStream);
|
|
@@ -505,7 +606,7 @@ export async function get(filePath, lineNumbers, field, readWholeFile = false) {
|
|
|
505
606
|
* Note: If the file doesn't exist and replacements is an object, it creates a new file with the specified replacements.
|
|
506
607
|
*/
|
|
507
608
|
export const replace = async (filePath, replacements, totalItems) => {
|
|
508
|
-
const fileTempPath = filePath
|
|
609
|
+
const fileTempPath = tempPathFor(filePath);
|
|
509
610
|
const isReplacementsObject = isObject(replacements);
|
|
510
611
|
const isReplacementsLineNumbered = isReplacementsObject && !Number.isNaN(Number(Object.keys(replacements)[0]));
|
|
511
612
|
if (await isExists(filePath)) {
|
|
@@ -581,19 +682,33 @@ export const replace = async (filePath, replacements, totalItems) => {
|
|
|
581
682
|
const replacementsKeys = Object.keys(replacements)
|
|
582
683
|
.map(Number)
|
|
583
684
|
.toSorted((a, b) => a - b);
|
|
584
|
-
await write(fileTempPath,
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
685
|
+
await write(fileTempPath,
|
|
686
|
+
// First-write for a compressed column: see append() — the
|
|
687
|
+
// unique temp path hides the ".gz" suffix from write().
|
|
688
|
+
filePath.endsWith(".gz")
|
|
689
|
+
? await gzip(`${"\n".repeat(replacementsKeys[0] - 1) +
|
|
690
|
+
replacementsKeys
|
|
691
|
+
.map((lineNumber, index) => index === 0 ||
|
|
692
|
+
lineNumber - replacementsKeys[index - 1] - 1 === 0
|
|
693
|
+
? replacements[lineNumber]
|
|
694
|
+
: "\n".repeat(lineNumber - replacementsKeys[index - 1] - 1) + replacements[lineNumber])
|
|
695
|
+
.join("\n")}\n`)
|
|
696
|
+
: `${"\n".repeat(replacementsKeys[0] - 1) +
|
|
697
|
+
replacementsKeys
|
|
698
|
+
.map((lineNumber, index) => index === 0 ||
|
|
699
|
+
lineNumber - replacementsKeys[index - 1] - 1 === 0
|
|
700
|
+
? replacements[lineNumber]
|
|
701
|
+
: "\n".repeat(lineNumber - replacementsKeys[index - 1] - 1) + replacements[lineNumber])
|
|
702
|
+
.join("\n")}\n`);
|
|
592
703
|
}
|
|
593
704
|
else {
|
|
594
705
|
if (!totalItems)
|
|
595
706
|
throw new Error("INVALID_PARAMETERS");
|
|
596
|
-
await write(fileTempPath,
|
|
707
|
+
await write(fileTempPath,
|
|
708
|
+
// First-write for a compressed column: see append().
|
|
709
|
+
filePath.endsWith(".gz")
|
|
710
|
+
? await gzip(`${`${replacements}\n`.repeat(totalItems)}\n`)
|
|
711
|
+
: `${`${replacements}\n`.repeat(totalItems)}\n`);
|
|
597
712
|
}
|
|
598
713
|
return [fileTempPath, filePath];
|
|
599
714
|
}
|
|
@@ -612,7 +727,7 @@ export const replace = async (filePath, replacements, totalItems) => {
|
|
|
612
727
|
*
|
|
613
728
|
*/
|
|
614
729
|
export const append = async (filePath, data) => {
|
|
615
|
-
const fileTempPath = filePath
|
|
730
|
+
const fileTempPath = tempPathFor(filePath);
|
|
616
731
|
try {
|
|
617
732
|
if (await isExists(filePath)) {
|
|
618
733
|
await copyFile(filePath, fileTempPath);
|
|
@@ -627,7 +742,13 @@ export const append = async (filePath, data) => {
|
|
|
627
742
|
}
|
|
628
743
|
}
|
|
629
744
|
else
|
|
630
|
-
await write(fileTempPath,
|
|
745
|
+
await write(fileTempPath,
|
|
746
|
+
// Unique temp paths no longer end with the original extension
|
|
747
|
+
// (.tmp/<col>.<pid>-<seq>), so write()'s suffix check can't
|
|
748
|
+
// gzip a first-write for a compressed column. Do it here.
|
|
749
|
+
filePath.endsWith(".gz")
|
|
750
|
+
? await gzip(`${Array.isArray(data) ? data.join("\n") : data}\n`)
|
|
751
|
+
: `${Array.isArray(data) ? data.join("\n") : data}\n`);
|
|
631
752
|
return [fileTempPath, filePath];
|
|
632
753
|
}
|
|
633
754
|
catch {
|
|
@@ -643,7 +764,7 @@ export const append = async (filePath, data) => {
|
|
|
643
764
|
*
|
|
644
765
|
*/
|
|
645
766
|
export const prepend = async (filePath, data) => {
|
|
646
|
-
const fileTempPath = filePath
|
|
767
|
+
const fileTempPath = tempPathFor(filePath);
|
|
647
768
|
if (await isExists(filePath)) {
|
|
648
769
|
if (!filePath.endsWith(".gz")) {
|
|
649
770
|
let fileHandle = null;
|
|
@@ -673,7 +794,7 @@ export const prepend = async (filePath, data) => {
|
|
|
673
794
|
}
|
|
674
795
|
}
|
|
675
796
|
else {
|
|
676
|
-
const fileChildTempPath = filePath
|
|
797
|
+
const fileChildTempPath = `${tempPathFor(filePath)}.child`;
|
|
677
798
|
try {
|
|
678
799
|
await write(fileChildTempPath, `${Array.isArray(data) ? data.join("\n") : data}\n`);
|
|
679
800
|
const escapedFilePath = escapeShellPath(filePath);
|
|
@@ -691,7 +812,12 @@ export const prepend = async (filePath, data) => {
|
|
|
691
812
|
}
|
|
692
813
|
else {
|
|
693
814
|
try {
|
|
694
|
-
await write(fileTempPath,
|
|
815
|
+
await write(fileTempPath,
|
|
816
|
+
// First-write for a compressed column: see append() — the
|
|
817
|
+
// unique temp path hides the ".gz" suffix from write().
|
|
818
|
+
filePath.endsWith(".gz")
|
|
819
|
+
? await gzip(`${Array.isArray(data) ? data.join("\n") : data}\n`)
|
|
820
|
+
: `${Array.isArray(data) ? data.join("\n") : data}\n`);
|
|
695
821
|
}
|
|
696
822
|
catch {
|
|
697
823
|
return [fileTempPath, null];
|
|
@@ -714,7 +840,7 @@ export const remove = async (filePath, linesToDelete) => {
|
|
|
714
840
|
: [Number(linesToDelete)];
|
|
715
841
|
if (linesToDelete.some(Number.isNaN))
|
|
716
842
|
throw new Error("UNVALID_LINE_NUMBERS");
|
|
717
|
-
const fileTempPath = filePath
|
|
843
|
+
const fileTempPath = tempPathFor(filePath);
|
|
718
844
|
try {
|
|
719
845
|
const escapedFilePath = escapeShellPath(filePath);
|
|
720
846
|
const escapedFileTempPath = escapeShellPath(fileTempPath);
|
package/dist/index.d.ts
CHANGED
|
@@ -29,6 +29,16 @@ export interface Options {
|
|
|
29
29
|
columns?: string[] | string;
|
|
30
30
|
sort?: Record<string, 1 | -1 | "asc" | "ASC" | "desc" | "DESC"> | string[] | string;
|
|
31
31
|
}
|
|
32
|
+
/** Options for the aggregate actions (`sum`, and later avg/min/max). */
|
|
33
|
+
export interface AggregateOptions {
|
|
34
|
+
/**
|
|
35
|
+
* Aggregate over the *elements* of an array-of-objects column instead of
|
|
36
|
+
* whole rows. The summed columns must be single-level child paths of the
|
|
37
|
+
* same array root (e.g. `"items.quantity"`); `where` keys that resolve as
|
|
38
|
+
* children of that root become per-element predicates.
|
|
39
|
+
*/
|
|
40
|
+
nested?: boolean;
|
|
41
|
+
}
|
|
32
42
|
export interface TableConfig {
|
|
33
43
|
compression?: boolean;
|
|
34
44
|
cache?: boolean;
|
|
@@ -145,6 +155,7 @@ export default class Inibase {
|
|
|
145
155
|
* @param {TableConfig} [config]
|
|
146
156
|
*/
|
|
147
157
|
createTable(tableName: string, schema?: Schema, config?: TableConfig): Promise<void>;
|
|
158
|
+
private _createTable;
|
|
148
159
|
private replaceStringInFile;
|
|
149
160
|
/**
|
|
150
161
|
* Update table schema or config
|
|
@@ -156,6 +167,7 @@ export default class Inibase {
|
|
|
156
167
|
updateTable(tableName: string, schema?: Schema, config?: TableConfig & {
|
|
157
168
|
name?: string;
|
|
158
169
|
}): Promise<void>;
|
|
170
|
+
private _updateTable;
|
|
159
171
|
private updateTableLocked;
|
|
160
172
|
/**
|
|
161
173
|
* Get table schema and config
|
|
@@ -183,6 +195,12 @@ export default class Inibase {
|
|
|
183
195
|
* persisted `{ expr, ast }` spec).
|
|
184
196
|
*/
|
|
185
197
|
private computedExprOf;
|
|
198
|
+
/**
|
|
199
|
+
* Every computed field of a schema as dotted keys (computed children of an
|
|
200
|
+
* array-of-objects column use their full path, e.g. `items.lineTotal`),
|
|
201
|
+
* with its raw expression — used for updateTable change detection.
|
|
202
|
+
*/
|
|
203
|
+
private computedEntries;
|
|
186
204
|
/**
|
|
187
205
|
* Compile every `computed` expression of a schema (which must already have
|
|
188
206
|
* ids assigned) into its persisted `{ expr, ast }` form, in dependency
|
|
@@ -229,6 +247,9 @@ export default class Inibase {
|
|
|
229
247
|
/** Merge evaluated per-line computed values into a `pathesContents` map
|
|
230
248
|
* as line-numbered replace records (encoded cells). */
|
|
231
249
|
private mergeComputedLineRecords;
|
|
250
|
+
/** Compare a freshly evaluated cell value against a stored (already
|
|
251
|
+
* decoded) one — both are scalar or index-aligned element arrays. */
|
|
252
|
+
private sameCellValue;
|
|
232
253
|
private isSimpleField;
|
|
233
254
|
private processSimpleField;
|
|
234
255
|
/**
|
|
@@ -314,6 +335,7 @@ export default class Inibase {
|
|
|
314
335
|
* locked on first touch, in first-touch order.
|
|
315
336
|
*/
|
|
316
337
|
begin(tables?: string[]): Promise<void>;
|
|
338
|
+
private _begin;
|
|
317
339
|
/**
|
|
318
340
|
* Publish every staged mutation atomically: per table (sorted), the
|
|
319
341
|
* pagination rename comes first (the atomic publication point readers
|
|
@@ -352,6 +374,7 @@ export default class Inibase {
|
|
|
352
374
|
get<TData extends Record<string, any> & Partial<Data>>(tableName: string, where?: string | number | (string | number)[] | Criteria, options?: Options, onlyOne?: boolean, onlyLinesNumbers?: false, _whereIsLinesNumbers?: boolean): Promise<(Data & TData)[] | null>;
|
|
353
375
|
get<_TData extends Record<string, any> & Partial<Data>>(tableName: string, where: string | number | (string | number)[] | Criteria | undefined, options: Options | undefined, onlyOne: false | undefined, onlyLinesNumbers: true, _whereIsLinesNumbers?: boolean): Promise<number[] | null>;
|
|
354
376
|
get<_TData extends Record<string, any> & Partial<Data>>(tableName: string, where: string | number | (string | number)[] | Criteria | undefined, options: Options | undefined, onlyOne: true, onlyLinesNumbers: true, _whereIsLinesNumbers?: boolean): Promise<number | null>;
|
|
377
|
+
private _get;
|
|
355
378
|
private getOnce;
|
|
356
379
|
/**
|
|
357
380
|
* Create new item(s) in a table
|
|
@@ -366,6 +389,7 @@ export default class Inibase {
|
|
|
366
389
|
post<TData extends Record<string, any> & Partial<Data>>(tableName: string, data: (Data & TData)[], options?: Options, returnPostedData?: boolean): Promise<string[]>;
|
|
367
390
|
post<TData extends Record<string, any> & Partial<Data>>(tableName: string, data: Data & TData, options: Options | undefined, returnPostedData: true): Promise<(Data & TData) | null>;
|
|
368
391
|
post<TData extends Record<string, any> & Partial<Data>>(tableName: string, data: (Data & TData)[], options: Options | undefined, returnPostedData: true): Promise<(Data & TData)[] | null>;
|
|
392
|
+
private _post;
|
|
369
393
|
/**
|
|
370
394
|
* Update item(s) in a table
|
|
371
395
|
*
|
|
@@ -380,6 +404,7 @@ export default class Inibase {
|
|
|
380
404
|
put<TData extends Record<string, any> & Partial<Data>>(tableName: string, data: Data & TData, where: number | string | (number | string)[] | Criteria | undefined, options: Options | undefined, returnUpdatedData: true | boolean, _whereIsLinesNumbers?: boolean): Promise<(Data & TData) | null>;
|
|
381
405
|
put<TData extends Record<string, any> & Partial<Data>>(tableName: string, data: (Data & TData)[], where: number | string | (number | string)[] | Criteria | undefined, options: Options | undefined, returnUpdatedData: true | boolean, _whereIsLinesNumbers?: boolean): Promise<(Data & TData)[] | null>;
|
|
382
406
|
put<TData extends Record<string, any> & Partial<Data>>(tableName: string, data: (Data & TData) | (Data & TData)[], where: number | string | (number | string)[] | Criteria | undefined, options: Options | undefined, returnUpdatedData: true | boolean, _whereIsLinesNumbers?: boolean): Promise<(Data & TData) | (Data & TData)[] | null>;
|
|
407
|
+
private _put;
|
|
383
408
|
/**
|
|
384
409
|
* Delete item(s) in a table
|
|
385
410
|
*
|
|
@@ -388,6 +413,7 @@ export default class Inibase {
|
|
|
388
413
|
* @return {boolean | null} {(Promise<boolean | null>)}
|
|
389
414
|
*/
|
|
390
415
|
delete(tableName: string, where?: number | string | (number | string)[] | Criteria, _whereIsLinesNumbers?: boolean, _cascadeGuard?: Set<string>): Promise<boolean | null>;
|
|
416
|
+
private _delete;
|
|
391
417
|
/**
|
|
392
418
|
* Cascade delete: remove rows in other tables whose `table`-typed schema
|
|
393
419
|
* fields reference the given rows. Reference columns store the numeric
|
|
@@ -405,8 +431,17 @@ export default class Inibase {
|
|
|
405
431
|
* @param {(number | string | (number | string)[] | Criteria)} [where]
|
|
406
432
|
* @return {*} {Promise<number | Record<string, number>>}
|
|
407
433
|
*/
|
|
408
|
-
sum(tableName: string, columns: string, where?: number | string | (number | string)[] | Criteria): Promise<number>;
|
|
409
|
-
sum(tableName: string, columns: string[], where?: number | string | (number | string)[] | Criteria): Promise<Record<string, number>>;
|
|
434
|
+
sum(tableName: string, columns: string, where?: number | string | (number | string)[] | Criteria, options?: AggregateOptions): Promise<number>;
|
|
435
|
+
sum(tableName: string, columns: string[], where?: number | string | (number | string)[] | Criteria, options?: AggregateOptions): Promise<Record<string, number>>;
|
|
436
|
+
/**
|
|
437
|
+
* Element-wise aggregation over an array-of-objects column (`nested` mode).
|
|
438
|
+
*
|
|
439
|
+
* `where` keys that resolve as children of the array root become per-element
|
|
440
|
+
* predicates (AND); everything else is a row-level filter. Only elements
|
|
441
|
+
* passing every element predicate, inside rows passing the row filters, are
|
|
442
|
+
* accumulated.
|
|
443
|
+
*/
|
|
444
|
+
private sumNested;
|
|
410
445
|
/**
|
|
411
446
|
* Generate average of column(s) in a table
|
|
412
447
|
*
|