rahman-resources 0.9.2 → 0.12.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 +105 -0
- package/bin/cli.js +114 -0
- package/bin/compose.mjs +287 -0
- package/bin/graph.mjs +247 -0
- package/bin/migrate.mjs +423 -0
- package/bin/update.mjs +413 -0
- package/lib/compose-solver.d.ts +179 -0
- package/lib/compose-solver.mjs +523 -0
- package/lib/compose-solver.test.mjs +483 -0
- package/lib/contract.ts +240 -0
- package/lib/dna.d.ts +65 -0
- package/lib/dna.mjs +239 -0
- package/lib/manifest.json +883 -185
- package/lib/merge3.d.ts +67 -0
- package/lib/merge3.mjs +431 -0
- package/lib/merge3.test.mjs +199 -0
- package/lib/migration-plan.d.ts +86 -0
- package/lib/migration-plan.mjs +414 -0
- package/lib/migration-plan.test.mjs +243 -0
- package/lib/slice-schema.json +13 -9
- package/lib/snapshot.d.ts +6 -0
- package/lib/snapshot.mjs +126 -0
- package/package.json +5 -2
package/lib/contract.ts
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Slice Composition Compiler — Phase A: Typed contract DSL.
|
|
3
|
+
*
|
|
4
|
+
* This module exposes the {@link defineSliceContract} factory plus the
|
|
5
|
+
* supporting type vocabulary. A slice contract is the typed, code-shaped
|
|
6
|
+
* sibling of the legacy `slice.manifest.json`. Both coexist during the
|
|
7
|
+
* migration; new slices should ship a contract while keeping the JSON
|
|
8
|
+
* manifest for back-compat with the npm CLI manifest pipeline.
|
|
9
|
+
*
|
|
10
|
+
* @module packages/cli/lib/contract
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
// Primitive vocabulary
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Identity providers the kitab knows about.
|
|
19
|
+
*
|
|
20
|
+
* The kitab itself only ships `convex` (see CLAUDE.md "NO Clerk"). The other
|
|
21
|
+
* literals exist so consumer projects forking the contract DSL can target
|
|
22
|
+
* a different provider without losing type safety.
|
|
23
|
+
*/
|
|
24
|
+
export type AuthProvider = "convex" | "clerk" | "next-auth" | "none";
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* RBAC permission string in `domain.action` shape — e.g. `"payment.refund"`.
|
|
28
|
+
*
|
|
29
|
+
* Enforced at compile time via a template-literal type, and at runtime by
|
|
30
|
+
* {@link defineSliceContract}. The runtime check rejects empty segments and
|
|
31
|
+
* any value with more than one dot.
|
|
32
|
+
*/
|
|
33
|
+
export type RBACPermission = `${string}.${string}`;
|
|
34
|
+
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
// `requires` block
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Convex table-namespace declaration for a slice.
|
|
41
|
+
*
|
|
42
|
+
* Every Convex table the slice owns must start with {@link prefix}. Operator
|
|
43
|
+
* decision 2026-05-12 mandates per-provider prefixes (e.g. `doku_`, `midtrans_`)
|
|
44
|
+
* to prevent the historical `paymentOrders` collision between sibling
|
|
45
|
+
* payment slices.
|
|
46
|
+
*/
|
|
47
|
+
export interface ConvexNamespace {
|
|
48
|
+
/** Required prefix — e.g. `"doku_"`. Must match `/^[a-z][a-z0-9_]*_$/`. */
|
|
49
|
+
prefix: string;
|
|
50
|
+
/** Tables this slice declares. Every entry must start with {@link prefix}. */
|
|
51
|
+
tables: string[];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Capabilities a slice REQUIRES the host application to satisfy before it
|
|
56
|
+
* can be composed in.
|
|
57
|
+
*/
|
|
58
|
+
export interface SliceContractRequires {
|
|
59
|
+
/** Identity provider the slice expects. Omit when slice is auth-agnostic. */
|
|
60
|
+
auth?: AuthProvider;
|
|
61
|
+
/** RBAC permission strings the slice will call `requirePermission(...)` with. */
|
|
62
|
+
rbac?: RBACPermission[];
|
|
63
|
+
/** Env var names (server scope). Validators read this for `.env.example` drift checks. */
|
|
64
|
+
env?: string[];
|
|
65
|
+
/** Convex table namespace the slice owns. */
|
|
66
|
+
convex?: ConvexNamespace;
|
|
67
|
+
/** Other slice ids that must be installed first. */
|
|
68
|
+
deps?: string[];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
// `provides` block
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Surface area a slice exposes to the host application + downstream slices.
|
|
77
|
+
*
|
|
78
|
+
* All arrays are optional. Empty / omitted means "nothing exposed in that
|
|
79
|
+
* category". The keys must match the literal segment of a conflict path —
|
|
80
|
+
* `tables`, `routes`, `hooks`, `events`, `components`.
|
|
81
|
+
*/
|
|
82
|
+
export interface SliceContractProvides {
|
|
83
|
+
/** Next.js route paths the slice mounts — e.g. `["/sign-in", "/sign-out"]`. */
|
|
84
|
+
routes?: string[];
|
|
85
|
+
/** Public hook export names — e.g. `["useDokuCheckout"]`. */
|
|
86
|
+
hooks?: string[];
|
|
87
|
+
/** Convex table names. Must match `requires.convex.prefix` if that is set. */
|
|
88
|
+
tables?: string[];
|
|
89
|
+
/** Event-bus event names the slice emits — e.g. `["payment.captured"]`. */
|
|
90
|
+
events?: string[];
|
|
91
|
+
/** Public component exports — e.g. `["DokuCheckoutButton"]`. */
|
|
92
|
+
components?: string[];
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ---------------------------------------------------------------------------
|
|
96
|
+
// Top-level contract
|
|
97
|
+
// ---------------------------------------------------------------------------
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The full Phase-A slice contract shape.
|
|
101
|
+
*
|
|
102
|
+
* @see {@link defineSliceContract} for the runtime-checked constructor.
|
|
103
|
+
*/
|
|
104
|
+
export interface SliceContract {
|
|
105
|
+
/** Slice slug — kebab-case, matches the folder name. */
|
|
106
|
+
id: string;
|
|
107
|
+
/** Semver — `MAJOR.MINOR.PATCH`, optional `-prerelease` and `+build`. */
|
|
108
|
+
version: string;
|
|
109
|
+
/** Host requirements. */
|
|
110
|
+
requires: SliceContractRequires;
|
|
111
|
+
/** Surface exposed to the host. */
|
|
112
|
+
provides: SliceContractProvides;
|
|
113
|
+
/**
|
|
114
|
+
* Known incompatibilities — `"<slug>:<provides-key>.<value>"`.
|
|
115
|
+
*
|
|
116
|
+
* Example: `"midtrans-payment:tables.paymentOrders"` declares that this
|
|
117
|
+
* slice collides with `midtrans-payment` over the `paymentOrders` table.
|
|
118
|
+
* The validator surfaces this as a P0 finding when both slices are
|
|
119
|
+
* composed into the same app.
|
|
120
|
+
*/
|
|
121
|
+
conflicts?: string[];
|
|
122
|
+
/** Map of previous-version → migration script id. */
|
|
123
|
+
migrationFrom?: Record<string, string>;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ---------------------------------------------------------------------------
|
|
127
|
+
// Runtime regexes
|
|
128
|
+
// ---------------------------------------------------------------------------
|
|
129
|
+
|
|
130
|
+
const KEBAB_CASE = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
|
|
131
|
+
// Plain semver — also tolerates pre-release + build metadata.
|
|
132
|
+
const SEMVER =
|
|
133
|
+
/^\d+\.\d+\.\d+(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
|
|
134
|
+
const PREFIX = /^[a-z][a-z0-9_]*_$/;
|
|
135
|
+
const CONFLICT = /^[a-z][a-z0-9-]*:(routes|hooks|tables|events|components)\.[A-Za-z0-9_\-\/]+$/;
|
|
136
|
+
const PERMISSION = /^[^.]+\.[^.]+$/;
|
|
137
|
+
|
|
138
|
+
// ---------------------------------------------------------------------------
|
|
139
|
+
// Factory
|
|
140
|
+
// ---------------------------------------------------------------------------
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Identity factory that runtime-validates a {@link SliceContract}.
|
|
144
|
+
*
|
|
145
|
+
* Throws a descriptive {@link Error} when the contract violates any of the
|
|
146
|
+
* Phase-A invariants:
|
|
147
|
+
*
|
|
148
|
+
* - `id` must be kebab-case (`^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$`).
|
|
149
|
+
* - `version` must be semver.
|
|
150
|
+
* - When `requires.convex` is set, every `requires.convex.tables[i]` must
|
|
151
|
+
* start with `requires.convex.prefix`.
|
|
152
|
+
* - When `requires.convex` is set and `provides.tables` is non-empty, every
|
|
153
|
+
* `provides.tables[i]` must start with `requires.convex.prefix`.
|
|
154
|
+
* - Each `conflicts[i]` must match
|
|
155
|
+
* `<kebab-slug>:<routes|hooks|tables|events|components>.<value>`.
|
|
156
|
+
* - Each `requires.rbac[i]` must be a `domain.action` pair (exactly one dot,
|
|
157
|
+
* non-empty halves).
|
|
158
|
+
*
|
|
159
|
+
* Returns the contract object unchanged so callers can write
|
|
160
|
+
* `export const contract = defineSliceContract({ ... })`.
|
|
161
|
+
*
|
|
162
|
+
* @param c The contract to validate.
|
|
163
|
+
* @returns The exact same object reference, narrowed to {@link SliceContract}.
|
|
164
|
+
*/
|
|
165
|
+
export function defineSliceContract(c: SliceContract): SliceContract {
|
|
166
|
+
if (!c || typeof c !== "object") {
|
|
167
|
+
throw new Error("defineSliceContract: expected an object, got " + typeof c);
|
|
168
|
+
}
|
|
169
|
+
if (typeof c.id !== "string" || !KEBAB_CASE.test(c.id)) {
|
|
170
|
+
throw new Error(`defineSliceContract: id "${String(c.id)}" must be kebab-case`);
|
|
171
|
+
}
|
|
172
|
+
if (typeof c.version !== "string" || !SEMVER.test(c.version)) {
|
|
173
|
+
throw new Error(`defineSliceContract(${c.id}): version "${String(c.version)}" is not semver`);
|
|
174
|
+
}
|
|
175
|
+
if (!c.requires || typeof c.requires !== "object") {
|
|
176
|
+
throw new Error(`defineSliceContract(${c.id}): requires must be an object`);
|
|
177
|
+
}
|
|
178
|
+
if (!c.provides || typeof c.provides !== "object") {
|
|
179
|
+
throw new Error(`defineSliceContract(${c.id}): provides must be an object`);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// RBAC
|
|
183
|
+
if (c.requires.rbac) {
|
|
184
|
+
if (!Array.isArray(c.requires.rbac)) {
|
|
185
|
+
throw new Error(`defineSliceContract(${c.id}): requires.rbac must be an array`);
|
|
186
|
+
}
|
|
187
|
+
for (const p of c.requires.rbac) {
|
|
188
|
+
if (typeof p !== "string" || !PERMISSION.test(p)) {
|
|
189
|
+
throw new Error(
|
|
190
|
+
`defineSliceContract(${c.id}): rbac entry "${String(p)}" must be "<domain>.<action>"`,
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Convex prefix invariants
|
|
197
|
+
const cx = c.requires.convex;
|
|
198
|
+
if (cx) {
|
|
199
|
+
if (typeof cx.prefix !== "string" || !PREFIX.test(cx.prefix)) {
|
|
200
|
+
throw new Error(
|
|
201
|
+
`defineSliceContract(${c.id}): convex.prefix "${String(cx.prefix)}" must match /^[a-z][a-z0-9_]*_$/`,
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
if (!Array.isArray(cx.tables)) {
|
|
205
|
+
throw new Error(`defineSliceContract(${c.id}): convex.tables must be an array`);
|
|
206
|
+
}
|
|
207
|
+
for (const t of cx.tables) {
|
|
208
|
+
if (typeof t !== "string" || !t.startsWith(cx.prefix)) {
|
|
209
|
+
throw new Error(
|
|
210
|
+
`defineSliceContract(${c.id}): convex.tables entry "${String(t)}" must start with prefix "${cx.prefix}"`,
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
if (Array.isArray(c.provides.tables)) {
|
|
215
|
+
for (const t of c.provides.tables) {
|
|
216
|
+
if (typeof t !== "string" || !t.startsWith(cx.prefix)) {
|
|
217
|
+
throw new Error(
|
|
218
|
+
`defineSliceContract(${c.id}): provides.tables entry "${String(t)}" must start with prefix "${cx.prefix}"`,
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Conflicts
|
|
226
|
+
if (c.conflicts) {
|
|
227
|
+
if (!Array.isArray(c.conflicts)) {
|
|
228
|
+
throw new Error(`defineSliceContract(${c.id}): conflicts must be an array`);
|
|
229
|
+
}
|
|
230
|
+
for (const cf of c.conflicts) {
|
|
231
|
+
if (typeof cf !== "string" || !CONFLICT.test(cf)) {
|
|
232
|
+
throw new Error(
|
|
233
|
+
`defineSliceContract(${c.id}): conflicts entry "${String(cf)}" must match "<slug>:<routes|hooks|tables|events|components>.<value>"`,
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
return c;
|
|
240
|
+
}
|
package/lib/dna.d.ts
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// Type definitions for the slice-DNA lineage tracker.
|
|
2
|
+
// Runtime in dna.mjs; this file is hand-authored types for tsc/ide consumers.
|
|
3
|
+
|
|
4
|
+
export interface LineageEntry {
|
|
5
|
+
/** "<sourceRepo>:<path>" — e.g. "superspace:frontend/slices/auth". */
|
|
6
|
+
from: string;
|
|
7
|
+
/** Optional destination, e.g. "kitab:0.7.0" or "careerpack:adopt". */
|
|
8
|
+
to?: string;
|
|
9
|
+
/** ISO 8601 UTC timestamp. */
|
|
10
|
+
at: string;
|
|
11
|
+
/** Transform tags applied during the harvest (e.g. "alias-rewrite", "clerk-strip"). */
|
|
12
|
+
transforms: string[];
|
|
13
|
+
/** Human/agent that triggered the lineage entry. */
|
|
14
|
+
actor?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface ConsumerAdoption {
|
|
18
|
+
adopted_at: string;
|
|
19
|
+
version: string;
|
|
20
|
+
/** 0-100, computed from file diff between kitab and consumer. */
|
|
21
|
+
drift_score: number;
|
|
22
|
+
last_synced_at?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface SliceDNA {
|
|
26
|
+
/** Slice slug, kebab-case. */
|
|
27
|
+
id: string;
|
|
28
|
+
created_at: string;
|
|
29
|
+
lineage: LineageEntry[];
|
|
30
|
+
/** Keyed by consumer name (notion, superspace, careerpack, content, rahmanef, cescadesigns). */
|
|
31
|
+
consumers: Record<string, ConsumerAdoption>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface LineageGraphNode {
|
|
35
|
+
id: string;
|
|
36
|
+
type: "slice" | "consumer" | "source";
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface LineageGraphEdge {
|
|
40
|
+
from: string;
|
|
41
|
+
to: string;
|
|
42
|
+
transforms?: string[];
|
|
43
|
+
at: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface LineageGraph {
|
|
47
|
+
nodes: LineageGraphNode[];
|
|
48
|
+
edges: LineageGraphEdge[];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function readDNA(slug: string): SliceDNA | null;
|
|
52
|
+
export function writeDNA(dna: SliceDNA): void;
|
|
53
|
+
export function appendLineage(slug: string, entry: LineageEntry): SliceDNA;
|
|
54
|
+
export function upsertConsumerAdoption(
|
|
55
|
+
slug: string,
|
|
56
|
+
consumer: string,
|
|
57
|
+
adoption: ConsumerAdoption,
|
|
58
|
+
): SliceDNA;
|
|
59
|
+
export function listAllDNA(): SliceDNA[];
|
|
60
|
+
export function buildLineageGraph(): LineageGraph;
|
|
61
|
+
|
|
62
|
+
/** Resolve the absolute path to `.kitab/lineage/`. */
|
|
63
|
+
export function getLineageDir(): string;
|
|
64
|
+
/** Resolve the absolute path to `.kitab/lineage/<slug>.dna.json`. */
|
|
65
|
+
export function getDNAPath(slug: string): string;
|
package/lib/dna.mjs
ADDED
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
// dna.mjs — slice-DNA lineage tracker (Phase C of the Slice Composition Compiler).
|
|
2
|
+
//
|
|
3
|
+
// Reader/writer for `.kitab/lineage/<slug>.dna.json`. Each DNA file records
|
|
4
|
+
// where a slice came from (lineage[]) and which consumers later adopted it
|
|
5
|
+
// (consumers{}). Types live in dna.d.ts.
|
|
6
|
+
//
|
|
7
|
+
// Runtime contract:
|
|
8
|
+
// - .kitab/lineage/ lives at the kitab repo root (resolved by walking up
|
|
9
|
+
// from this file). If the directory doesn't exist, write* creates it.
|
|
10
|
+
// - Files are pretty-printed JSON, trailing newline. Idempotent re-writes
|
|
11
|
+
// keep diffs clean.
|
|
12
|
+
//
|
|
13
|
+
// The functions here are deliberately synchronous despite the spec hinting
|
|
14
|
+
// at fs.promises — the consumers (CLI + MCP) are both single-shot processes
|
|
15
|
+
// where async I/O adds noise without buying parallelism. The d.ts mirrors
|
|
16
|
+
// this signature exactly.
|
|
17
|
+
|
|
18
|
+
import {
|
|
19
|
+
existsSync,
|
|
20
|
+
mkdirSync,
|
|
21
|
+
readFileSync,
|
|
22
|
+
readdirSync,
|
|
23
|
+
writeFileSync,
|
|
24
|
+
} from "node:fs";
|
|
25
|
+
import path from "node:path";
|
|
26
|
+
import { fileURLToPath } from "node:url";
|
|
27
|
+
|
|
28
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
29
|
+
|
|
30
|
+
// Walk up from packages/cli/lib/ to the kitab repo root (the directory
|
|
31
|
+
// containing `packages/`). Anchors the .kitab/lineage path regardless of
|
|
32
|
+
// where the CLI is invoked from.
|
|
33
|
+
function findRepoRoot() {
|
|
34
|
+
let dir = __dirname;
|
|
35
|
+
for (let i = 0; i < 8; i++) {
|
|
36
|
+
if (existsSync(path.join(dir, "packages")) && existsSync(path.join(dir, "package.json"))) {
|
|
37
|
+
return dir;
|
|
38
|
+
}
|
|
39
|
+
const parent = path.dirname(dir);
|
|
40
|
+
if (parent === dir) break;
|
|
41
|
+
dir = parent;
|
|
42
|
+
}
|
|
43
|
+
// Fallback: assume cwd is the repo root.
|
|
44
|
+
return process.cwd();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const REPO_ROOT = findRepoRoot();
|
|
48
|
+
|
|
49
|
+
export function getLineageDir() {
|
|
50
|
+
return path.join(REPO_ROOT, ".kitab", "lineage");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function getDNAPath(slug) {
|
|
54
|
+
if (!/^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(slug)) {
|
|
55
|
+
throw new Error(`dna: invalid slug "${slug}" — must be kebab-case`);
|
|
56
|
+
}
|
|
57
|
+
return path.join(getLineageDir(), `${slug}.dna.json`);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function ensureDir() {
|
|
61
|
+
const d = getLineageDir();
|
|
62
|
+
if (!existsSync(d)) mkdirSync(d, { recursive: true });
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** @returns {import("./dna").SliceDNA | null} */
|
|
66
|
+
export function readDNA(slug) {
|
|
67
|
+
const p = getDNAPath(slug);
|
|
68
|
+
if (!existsSync(p)) return null;
|
|
69
|
+
try {
|
|
70
|
+
const raw = readFileSync(p, "utf8");
|
|
71
|
+
const parsed = JSON.parse(raw);
|
|
72
|
+
return normalizeDNA(parsed, slug);
|
|
73
|
+
} catch (err) {
|
|
74
|
+
throw new Error(`dna: failed to parse ${p}: ${err.message ?? err}`);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** @param {import("./dna").SliceDNA} dna */
|
|
79
|
+
export function writeDNA(dna) {
|
|
80
|
+
if (!dna || typeof dna !== "object" || !dna.id) {
|
|
81
|
+
throw new Error("dna.writeDNA: dna.id is required");
|
|
82
|
+
}
|
|
83
|
+
ensureDir();
|
|
84
|
+
const normalized = normalizeDNA(dna, dna.id);
|
|
85
|
+
const p = getDNAPath(normalized.id);
|
|
86
|
+
writeFileSync(p, JSON.stringify(normalized, null, 2) + "\n");
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** @param {string} slug @param {import("./dna").LineageEntry} entry */
|
|
90
|
+
export function appendLineage(slug, entry) {
|
|
91
|
+
const existing = readDNA(slug) ?? newDNA(slug);
|
|
92
|
+
if (!entry || typeof entry !== "object" || !entry.from || !entry.at) {
|
|
93
|
+
throw new Error("dna.appendLineage: entry.from and entry.at are required");
|
|
94
|
+
}
|
|
95
|
+
existing.lineage.push({
|
|
96
|
+
from: entry.from,
|
|
97
|
+
to: entry.to,
|
|
98
|
+
at: entry.at,
|
|
99
|
+
transforms: Array.isArray(entry.transforms) ? entry.transforms : [],
|
|
100
|
+
actor: entry.actor,
|
|
101
|
+
});
|
|
102
|
+
writeDNA(existing);
|
|
103
|
+
return existing;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** @param {string} slug @param {string} consumer @param {import("./dna").ConsumerAdoption} adoption */
|
|
107
|
+
export function upsertConsumerAdoption(slug, consumer, adoption) {
|
|
108
|
+
if (!consumer) throw new Error("dna.upsertConsumerAdoption: consumer is required");
|
|
109
|
+
if (!adoption || typeof adoption !== "object") {
|
|
110
|
+
throw new Error("dna.upsertConsumerAdoption: adoption object is required");
|
|
111
|
+
}
|
|
112
|
+
const existing = readDNA(slug) ?? newDNA(slug);
|
|
113
|
+
existing.consumers[consumer] = {
|
|
114
|
+
adopted_at: adoption.adopted_at,
|
|
115
|
+
version: adoption.version,
|
|
116
|
+
drift_score: clampDrift(adoption.drift_score),
|
|
117
|
+
last_synced_at: adoption.last_synced_at,
|
|
118
|
+
};
|
|
119
|
+
writeDNA(existing);
|
|
120
|
+
return existing;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** @returns {import("./dna").SliceDNA[]} */
|
|
124
|
+
export function listAllDNA() {
|
|
125
|
+
const d = getLineageDir();
|
|
126
|
+
if (!existsSync(d)) return [];
|
|
127
|
+
const files = readdirSync(d).filter((f) => f.endsWith(".dna.json"));
|
|
128
|
+
const out = [];
|
|
129
|
+
for (const f of files) {
|
|
130
|
+
const slug = f.replace(/\.dna\.json$/, "");
|
|
131
|
+
const dna = readDNA(slug);
|
|
132
|
+
if (dna) out.push(dna);
|
|
133
|
+
}
|
|
134
|
+
// Stable ordering — alphabetical by slug.
|
|
135
|
+
out.sort((a, b) => a.id.localeCompare(b.id));
|
|
136
|
+
return out;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** @returns {import("./dna").LineageGraph} */
|
|
140
|
+
export function buildLineageGraph() {
|
|
141
|
+
const all = listAllDNA();
|
|
142
|
+
/** @type {Map<string, import("./dna").LineageGraphNode>} */
|
|
143
|
+
const nodes = new Map();
|
|
144
|
+
/** @type {import("./dna").LineageGraphEdge[]} */
|
|
145
|
+
const edges = [];
|
|
146
|
+
|
|
147
|
+
function addNode(id, type) {
|
|
148
|
+
if (!nodes.has(id)) nodes.set(id, { id, type });
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
for (const dna of all) {
|
|
152
|
+
const sliceId = `slice:${dna.id}`;
|
|
153
|
+
addNode(sliceId, "slice");
|
|
154
|
+
|
|
155
|
+
// lineage edges: source → slice
|
|
156
|
+
for (const entry of dna.lineage ?? []) {
|
|
157
|
+
const sourceId = `source:${entry.from}`;
|
|
158
|
+
addNode(sourceId, "source");
|
|
159
|
+
// If `to` looks like a different slice / kitab tag, prefer the slice node;
|
|
160
|
+
// otherwise treat `to` as the kitab itself and target the slice node.
|
|
161
|
+
const targetId = entry.to && entry.to.startsWith("kitab:") ? sliceId : sliceId;
|
|
162
|
+
edges.push({
|
|
163
|
+
from: sourceId,
|
|
164
|
+
to: targetId,
|
|
165
|
+
transforms: entry.transforms,
|
|
166
|
+
at: entry.at,
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// consumer edges: slice → consumer
|
|
171
|
+
for (const [consumerName, adoption] of Object.entries(dna.consumers ?? {})) {
|
|
172
|
+
const consumerId = `consumer:${consumerName}`;
|
|
173
|
+
addNode(consumerId, "consumer");
|
|
174
|
+
edges.push({
|
|
175
|
+
from: sliceId,
|
|
176
|
+
to: consumerId,
|
|
177
|
+
transforms: [`drift:${adoption.drift_score}`, `v${adoption.version}`],
|
|
178
|
+
at: adoption.adopted_at,
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return { nodes: [...nodes.values()], edges };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// ─── helpers ──────────────────────────────────────────────────────────────
|
|
187
|
+
|
|
188
|
+
/** Build a fresh DNA shell for a slug. */
|
|
189
|
+
function newDNA(slug) {
|
|
190
|
+
return {
|
|
191
|
+
id: slug,
|
|
192
|
+
created_at: new Date().toISOString(),
|
|
193
|
+
lineage: [],
|
|
194
|
+
consumers: {},
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Fill in defaults + drop unknown fields. */
|
|
199
|
+
function normalizeDNA(raw, slug) {
|
|
200
|
+
const out = {
|
|
201
|
+
id: typeof raw.id === "string" ? raw.id : slug,
|
|
202
|
+
created_at:
|
|
203
|
+
typeof raw.created_at === "string" ? raw.created_at : new Date().toISOString(),
|
|
204
|
+
lineage: [],
|
|
205
|
+
consumers: {},
|
|
206
|
+
};
|
|
207
|
+
if (Array.isArray(raw.lineage)) {
|
|
208
|
+
for (const e of raw.lineage) {
|
|
209
|
+
if (!e || typeof e !== "object" || !e.from || !e.at) continue;
|
|
210
|
+
out.lineage.push({
|
|
211
|
+
from: String(e.from),
|
|
212
|
+
to: e.to ? String(e.to) : undefined,
|
|
213
|
+
at: String(e.at),
|
|
214
|
+
transforms: Array.isArray(e.transforms) ? e.transforms.map(String) : [],
|
|
215
|
+
actor: e.actor ? String(e.actor) : undefined,
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
if (raw.consumers && typeof raw.consumers === "object") {
|
|
220
|
+
for (const [k, v] of Object.entries(raw.consumers)) {
|
|
221
|
+
if (!v || typeof v !== "object") continue;
|
|
222
|
+
out.consumers[k] = {
|
|
223
|
+
adopted_at: String(v.adopted_at ?? ""),
|
|
224
|
+
version: String(v.version ?? "0.0.0"),
|
|
225
|
+
drift_score: clampDrift(v.drift_score),
|
|
226
|
+
last_synced_at: v.last_synced_at ? String(v.last_synced_at) : undefined,
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
return out;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function clampDrift(n) {
|
|
234
|
+
const x = Number(n);
|
|
235
|
+
if (!Number.isFinite(x)) return 0;
|
|
236
|
+
if (x < 0) return 0;
|
|
237
|
+
if (x > 100) return 100;
|
|
238
|
+
return Math.round(x);
|
|
239
|
+
}
|