@nebutra/tenant-store 0.2.2 → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nebutra/tenant-store",
3
- "version": "0.2.2",
3
+ "version": "0.2.3",
4
4
  "description": "Neutral lowest-layer multi-tenant primitives: per-(tenant,resource) serialization lock + tenant-scoped in-memory store base. Depended on by atelier-canvas and reel; depends on neither.",
5
5
  "private": false,
6
6
  "license": "MIT",
@@ -10,10 +10,14 @@
10
10
  "category": "platform",
11
11
  "summary": "Multi-tenant lock + store base shared by canvas/graph features"
12
12
  },
13
- "main": "./src/index.ts",
14
- "types": "./src/index.ts",
13
+ "main": "./dist/index.js",
14
+ "types": "./dist/index.d.ts",
15
15
  "exports": {
16
- ".": "./src/index.ts"
16
+ ".": {
17
+ "types": "./dist/index.d.ts",
18
+ "import": "./dist/index.js",
19
+ "default": "./dist/index.js"
20
+ }
17
21
  },
18
22
  "dependencies": {},
19
23
  "devDependencies": {
@@ -34,6 +38,12 @@
34
38
  "publishConfig": {
35
39
  "access": "public"
36
40
  },
41
+ "files": [
42
+ "dist",
43
+ "README.md",
44
+ "LICENSE",
45
+ "CHANGELOG.md"
46
+ ],
37
47
  "scripts": {
38
48
  "build": "tsup",
39
49
  "test": "vitest run",
@@ -1,17 +0,0 @@
1
-
2
- > @nebutra/tenant-store@0.2.2 build /Users/tseka_luk/Documents/Nebutra-SaaS-Lab/Nebutra-Sailor/packages/platform/tenant-store
3
- > tsup
4
-
5
- CLI Building entry: src/index.ts
6
- CLI Using tsconfig: tsconfig.json
7
- CLI tsup v8.5.1
8
- CLI Using tsup config: /Users/tseka_luk/Documents/Nebutra-SaaS-Lab/Nebutra-Sailor/packages/platform/tenant-store/tsup.config.ts
9
- CLI Target: es2022
10
- CLI Cleaning output folder
11
- ESM Build start
12
- ESM dist/index.js 1.83 KB
13
- ESM dist/index.js.map 6.80 KB
14
- ESM ⚡️ Build success in 57ms
15
- DTS Build start
16
- DTS ⚡️ Build success in 2951ms
17
- DTS dist/index.d.ts 3.40 KB
@@ -1,14 +0,0 @@
1
-
2
- > @nebutra/tenant-store@0.2.1 test /Users/tseka_luk/Documents/Nebutra-SaaS-Lab/Nebutra-Sailor/packages/platform/tenant-store
3
- > vitest run
4
-
5
-
6
- RUN v4.1.4 /Users/tseka_luk/Documents/Nebutra-SaaS-Lab/Nebutra-Sailor/packages/platform/tenant-store
7
-
8
- ✓ src/__tests__/tenant-store.test.ts (12 tests) 88ms
9
-
10
- Test Files 1 passed (1)
11
- Tests 12 passed (12)
12
- Start at 09:38:28
13
- Duration 301ms (transform 32ms, setup 0ms, import 46ms, tests 88ms, environment 0ms)
14
-
@@ -1,4 +0,0 @@
1
-
2
- > @nebutra/tenant-store@0.2.2 typecheck /Users/tseka_luk/Documents/Nebutra-SaaS-Lab/Nebutra-Sailor/packages/platform/tenant-store
3
- > tsc --noEmit
4
-
@@ -1,123 +0,0 @@
1
- import { beforeEach, describe, expect, it } from "vitest";
2
- import { _resetTenantLocks, withTenantLock } from "../lock";
3
- import { InMemoryTenantStore } from "../store";
4
-
5
- describe("withTenantLock", () => {
6
- beforeEach(() => _resetTenantLocks());
7
-
8
- it("serializes calls for the same (tenant, resource) in submission order", async () => {
9
- const order: number[] = [];
10
- const slow = (n: number, ms: number) =>
11
- withTenantLock("t1", "r1", async () => {
12
- await new Promise((r) => setTimeout(r, ms));
13
- order.push(n);
14
- });
15
- // 1 submitted first but slowest — must still finish before 2 and 3.
16
- await Promise.all([slow(1, 30), slow(2, 5), slow(3, 1)]);
17
- expect(order).toEqual([1, 2, 3]);
18
- });
19
-
20
- it("runs different resources concurrently", async () => {
21
- const order: string[] = [];
22
- await Promise.all([
23
- withTenantLock("t1", "a", async () => {
24
- await new Promise((r) => setTimeout(r, 20));
25
- order.push("a");
26
- }),
27
- withTenantLock("t1", "b", async () => {
28
- order.push("b");
29
- }),
30
- ]);
31
- // b (no wait) finishes before slow a → proves no cross-resource serialization.
32
- expect(order).toEqual(["b", "a"]);
33
- });
34
-
35
- it("isolates the same resourceId across tenants", async () => {
36
- const order: string[] = [];
37
- await Promise.all([
38
- withTenantLock("tenantA", "shared", async () => {
39
- await new Promise((r) => setTimeout(r, 20));
40
- order.push("A");
41
- }),
42
- withTenantLock("tenantB", "shared", async () => {
43
- order.push("B");
44
- }),
45
- ]);
46
- expect(order).toEqual(["B", "A"]);
47
- });
48
-
49
- it("does not deadlock the next waiter when a section rejects", async () => {
50
- const failing = withTenantLock("t1", "r1", async () => {
51
- throw new Error("boom");
52
- });
53
- await expect(failing).rejects.toThrow("boom");
54
- const ok = await withTenantLock("t1", "r1", async () => "recovered");
55
- expect(ok).toBe("recovered");
56
- });
57
-
58
- it("returns the section's value", async () => {
59
- await expect(withTenantLock("t1", "r1", async () => 42)).resolves.toBe(42);
60
- });
61
- });
62
-
63
- interface Row {
64
- readonly id: string;
65
- readonly tenantId: string;
66
- readonly value: number;
67
- }
68
-
69
- describe("InMemoryTenantStore", () => {
70
- let store: InMemoryTenantStore<Row>;
71
- beforeEach(() => {
72
- store = new InMemoryTenantStore<Row>();
73
- });
74
-
75
- it("round-trips a written record", async () => {
76
- const row: Row = { id: "1", tenantId: "t1", value: 7 };
77
- await store.write("t1", "1", row);
78
- expect(await store.read("t1", "1")).toEqual(row);
79
- });
80
-
81
- it("returns null for a missing record", async () => {
82
- expect(await store.read("t1", "missing")).toBeNull();
83
- });
84
-
85
- it("isolates records across tenants (read)", async () => {
86
- await store.write("tenantA", "1", { id: "1", tenantId: "tenantA", value: 1 });
87
- expect(await store.read("tenantB", "1")).toBeNull();
88
- });
89
-
90
- it("isolates records across tenants (list)", async () => {
91
- await store.write("tenantA", "1", { id: "1", tenantId: "tenantA", value: 1 });
92
- await store.write("tenantA", "2", { id: "2", tenantId: "tenantA", value: 2 });
93
- await store.write("tenantB", "3", { id: "3", tenantId: "tenantB", value: 3 });
94
- const a = await store.listByTenant("tenantA");
95
- expect(a.map((r) => r.id).sort()).toEqual(["1", "2"]);
96
- expect(await store.listByTenant("tenantB")).toHaveLength(1);
97
- });
98
-
99
- it("delete() removes only the targeted record, tenant-checked", async () => {
100
- await store.write("tenantA", "1", { id: "1", tenantId: "tenantA", value: 1 });
101
- await store.write("tenantA", "2", { id: "2", tenantId: "tenantA", value: 2 });
102
- expect(await store.delete("tenantB", "1")).toBe(false); // wrong tenant
103
- expect(await store.read("tenantA", "1")).not.toBeNull();
104
- expect(await store.delete("tenantA", "1")).toBe(true);
105
- expect(await store.read("tenantA", "1")).toBeNull();
106
- expect(await store.delete("tenantA", "1")).toBe(false); // already gone
107
- expect(await store.listByTenant("tenantA")).toHaveLength(1);
108
- });
109
-
110
- it("size() counts records across tenants", async () => {
111
- expect(store.size()).toBe(0);
112
- await store.write("tenantA", "1", { id: "1", tenantId: "tenantA", value: 1 });
113
- await store.write("tenantB", "1", { id: "1", tenantId: "tenantB", value: 1 });
114
- expect(store.size()).toBe(2);
115
- });
116
-
117
- it("clear() empties all tenants", async () => {
118
- await store.write("t1", "1", { id: "1", tenantId: "t1", value: 1 });
119
- store.clear();
120
- expect(await store.read("t1", "1")).toBeNull();
121
- expect(await store.listByTenant("t1")).toHaveLength(0);
122
- });
123
- });
package/src/index.ts DELETED
@@ -1,14 +0,0 @@
1
- /**
2
- * @nebutra/tenant-store — neutral lowest-layer multi-tenant primitives.
3
- *
4
- * Depended on by `@nebutra/atelier-canvas` and `@nebutra/reel` (and new
5
- * tenant-scoped features); depends on neither. This is the explicit lower
6
- * contract those siblings share instead of one importing the other.
7
- */
8
-
9
- export { _resetTenantLocks, withTenantLock } from "./lock";
10
- export {
11
- InMemoryTenantStore,
12
- type TenantOwned,
13
- type TenantScopedStore,
14
- } from "./store";
package/src/lock.ts DELETED
@@ -1,61 +0,0 @@
1
- /**
2
- * Per-(tenant, resource) serialization.
3
- *
4
- * A process-global lock keyed only by resource id would be correct for a
5
- * single-user desktop app but wrong for a multi-tenant server (one tenant's
6
- * lock could be observed by another, and ids may collide). Here the key is
7
- * `tenantId:resourceId`, so isolation holds.
8
- *
9
- * Scope is in-process: it serializes concurrent mutations of one resource
10
- * within a single server instance. Cross-instance correctness comes from a
11
- * persist-then-broadcast ordering at the call site (last write wins on the
12
- * row), not from this lock. Swapping to a distributed lock (Redis/etcd) is a
13
- * documented future change that does NOT alter any call site — replace the
14
- * body of `withTenantLock` here only.
15
- */
16
-
17
- /** Tail of the promise chain per (tenant, resource). */
18
- const _tails = new Map<string, Promise<unknown>>();
19
-
20
- function key(tenantId: string, resourceId: string): string {
21
- return `${tenantId}:${resourceId}`;
22
- }
23
-
24
- /**
25
- * Run `fn` with exclusive access to a single (tenant, resource). Calls for the
26
- * same resource run strictly in submission order; different resources (or the
27
- * same resource under different tenants) run freely in parallel.
28
- */
29
- export async function withTenantLock<T>(
30
- tenantId: string,
31
- resourceId: string,
32
- fn: () => Promise<T>,
33
- ): Promise<T> {
34
- const k = key(tenantId, resourceId);
35
- const prev = _tails.get(k) ?? Promise.resolve();
36
-
37
- // Chain onto the previous holder; `.then(fn, fn)` runs `fn` whether the
38
- // prior critical section resolved or rejected, so one failure doesn't
39
- // deadlock the next waiter.
40
- const run = prev.then(fn, fn);
41
-
42
- // The new tail is `run` with its outcome neutralized, so the next waiter
43
- // never sees this section's value or error.
44
- const tail = run.then(
45
- () => undefined,
46
- () => undefined,
47
- );
48
- _tails.set(k, tail);
49
-
50
- try {
51
- return await run;
52
- } finally {
53
- // Bound the map: if no later caller replaced the tail, drop the entry.
54
- if (_tails.get(k) === tail) _tails.delete(k);
55
- }
56
- }
57
-
58
- /** Test helper — clears all lock chains. */
59
- export function _resetTenantLocks(): void {
60
- _tails.clear();
61
- }
package/src/store.ts DELETED
@@ -1,87 +0,0 @@
1
- /**
2
- * Tenant-scoped persistence primitives.
3
- *
4
- * Two artifacts that were independently re-implemented per feature
5
- * (atelier-canvas scenes, reel graphs, knowledge-rag chunks) and are now
6
- * shared:
7
- *
8
- * - `TenantScopedStore<T>` — the minimal honest read contract every
9
- * tenant-scoped store exposes. It deliberately does NOT prescribe
10
- * `create` / `save`, because domain write signatures legitimately differ
11
- * (a scene vs. nodes+edges vs. a chunk batch). Over-unifying those would be
12
- * a leaky abstraction; the shared part is *reads* + isolation.
13
- *
14
- * - `InMemoryTenantStore<T>` — the storage MECHANICS (a Map keyed by
15
- * `tenantId:id`, never returning rows across tenants, tenant-filtered
16
- * listing). Concrete in-memory stores compose this and add their own
17
- * typed `create` / `save` on top, instead of copying the Map+key+filter
18
- * boilerplate.
19
- *
20
- * The same tenant-isolation property a Prisma adapter gets from RLS is
21
- * enforced here structurally by the composite key.
22
- */
23
-
24
- /** Records carry their owning tenant so listing can be verified, not trusted. */
25
- export interface TenantOwned {
26
- readonly tenantId: string;
27
- }
28
-
29
- /** Minimal read contract shared by every tenant-scoped store. */
30
- export interface TenantScopedStore<TRecord extends TenantOwned> {
31
- read(tenantId: string, id: string): Promise<TRecord | null>;
32
- listByTenant(tenantId: string): Promise<readonly TRecord[]>;
33
- }
34
-
35
- function key(tenantId: string, id: string): string {
36
- return `${tenantId}:${id}`;
37
- }
38
-
39
- /**
40
- * Reusable in-memory storage core. Default for tests and flag-gated demos;
41
- * production features wrap a Prisma/RLS adapter behind the same read contract.
42
- */
43
- export class InMemoryTenantStore<TRecord extends TenantOwned>
44
- implements TenantScopedStore<TRecord>
45
- {
46
- private readonly rows = new Map<string, TRecord>();
47
-
48
- async read(tenantId: string, id: string): Promise<TRecord | null> {
49
- const row = this.rows.get(key(tenantId, id));
50
- // Defense in depth: the composite key already isolates, but verify the
51
- // stored record's own tenantId too so a mis-keyed write can't leak.
52
- return row && row.tenantId === tenantId ? row : null;
53
- }
54
-
55
- async write(tenantId: string, id: string, record: TRecord): Promise<TRecord> {
56
- if (record.tenantId !== tenantId) {
57
- throw new Error(
58
- `tenant-store: record.tenantId "${record.tenantId}" does not match write tenant "${tenantId}". ` +
59
- `Fix: pass a record whose tenantId equals the tenant you are writing for.`,
60
- );
61
- }
62
- this.rows.set(key(tenantId, id), record);
63
- return record;
64
- }
65
-
66
- async listByTenant(tenantId: string): Promise<readonly TRecord[]> {
67
- return [...this.rows.values()].filter((r) => r.tenantId === tenantId);
68
- }
69
-
70
- /** Delete one record. Returns true if it existed (and belonged to tenant). */
71
- async delete(tenantId: string, id: string): Promise<boolean> {
72
- const k = key(tenantId, id);
73
- const row = this.rows.get(k);
74
- if (!row || row.tenantId !== tenantId) return false;
75
- return this.rows.delete(k);
76
- }
77
-
78
- /** Total record count across all tenants (diagnostics/health only). */
79
- size(): number {
80
- return this.rows.size;
81
- }
82
-
83
- /** Test helper. */
84
- clear(): void {
85
- this.rows.clear();
86
- }
87
- }
package/tsconfig.json DELETED
@@ -1,10 +0,0 @@
1
- {
2
- "extends": "../../../tsconfig.base.json",
3
- "compilerOptions": {
4
- "outDir": "dist",
5
- "rootDir": "src",
6
- "incremental": false
7
- },
8
- "include": ["src/**/*.ts"],
9
- "exclude": ["node_modules", "dist"]
10
- }
package/tsup.config.ts DELETED
@@ -1,10 +0,0 @@
1
- import { defineConfig } from "tsup";
2
-
3
- export default defineConfig({
4
- entry: ["src/index.ts"],
5
- format: ["esm"],
6
- dts: true,
7
- clean: true,
8
- sourcemap: true,
9
- target: "es2022",
10
- });