@nebutra/tenant-store 0.2.2 → 2.0.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/CHANGELOG.md +2 -0
- package/package.json +16 -4
- package/.turbo/turbo-build.log +0 -17
- package/.turbo/turbo-test.log +0 -14
- package/.turbo/turbo-typecheck.log +0 -4
- package/src/__tests__/tenant-store.test.ts +0 -123
- package/src/index.ts +0 -14
- package/src/lock.ts +0 -61
- package/src/store.ts +0 -87
- package/tsconfig.json +0 -10
- package/tsup.config.ts +0 -10
package/CHANGELOG.md
CHANGED
package/package.json
CHANGED
|
@@ -1,19 +1,25 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nebutra/tenant-store",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "2.0.0",
|
|
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",
|
|
7
7
|
"type": "module",
|
|
8
8
|
"nebutra": {
|
|
9
|
+
"status": "foundation",
|
|
10
|
+
"graph": "core",
|
|
9
11
|
"featureId": "tenant-store",
|
|
10
12
|
"category": "platform",
|
|
11
13
|
"summary": "Multi-tenant lock + store base shared by canvas/graph features"
|
|
12
14
|
},
|
|
13
|
-
"main": "./
|
|
14
|
-
"types": "./
|
|
15
|
+
"main": "./dist/index.js",
|
|
16
|
+
"types": "./dist/index.d.ts",
|
|
15
17
|
"exports": {
|
|
16
|
-
".":
|
|
18
|
+
".": {
|
|
19
|
+
"types": "./dist/index.d.ts",
|
|
20
|
+
"import": "./dist/index.js",
|
|
21
|
+
"default": "./dist/index.js"
|
|
22
|
+
}
|
|
17
23
|
},
|
|
18
24
|
"dependencies": {},
|
|
19
25
|
"devDependencies": {
|
|
@@ -34,6 +40,12 @@
|
|
|
34
40
|
"publishConfig": {
|
|
35
41
|
"access": "public"
|
|
36
42
|
},
|
|
43
|
+
"files": [
|
|
44
|
+
"dist",
|
|
45
|
+
"README.md",
|
|
46
|
+
"LICENSE",
|
|
47
|
+
"CHANGELOG.md"
|
|
48
|
+
],
|
|
37
49
|
"scripts": {
|
|
38
50
|
"build": "tsup",
|
|
39
51
|
"test": "vitest run",
|
package/.turbo/turbo-build.log
DELETED
|
@@ -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
|
package/.turbo/turbo-test.log
DELETED
|
@@ -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,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