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 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
- - Only top-level schema fields can be computed in v1; array contents are
946
- reachable through helpers. Missing link values make a single bare path
947
- evaluate to null (stored empty).
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>
@@ -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
@@ -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
- // A bare (non-helper) reference may only address fields outside arrays;
271
- // array contents are reachable exclusively through sum/count/avg/min/max.
272
- if (!inHelper && arrayFieldId !== null)
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
- const depth = lockDepths.get(resolvedPath);
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.replace(/([^/]+)\/?$/, ".tmp/$1");
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, `${"\n".repeat(replacementsKeys[0] - 1) +
585
- replacementsKeys
586
- .map((lineNumber, index) => index === 0 ||
587
- lineNumber - replacementsKeys[index - 1] - 1 === 0
588
- ? replacements[lineNumber]
589
- : "\n".repeat(lineNumber - replacementsKeys[index - 1] - 1) +
590
- replacements[lineNumber])
591
- .join("\n")}\n`);
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, `${`${replacements}\n`.repeat(totalItems)}\n`);
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.replace(/([^/]+)\/?$/, ".tmp/$1");
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, `${Array.isArray(data) ? data.join("\n") : data}\n`);
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.replace(/([^/]+)\/?$/, ".tmp/$1");
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.replace(/([^/]+)\/?$/, ".tmp/tmp_$1");
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, `${Array.isArray(data) ? data.join("\n") : data}\n`);
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.replace(/([^/]+)\/?$/, ".tmp/$1");
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
  *