@skastr0/prism-sdk 0.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.
@@ -0,0 +1,360 @@
1
+ import { mkdir, unlink, readFile, open } from "node:fs/promises";
2
+ import { dirname } from "node:path";
3
+ import { existsSync } from "node:fs";
4
+ import { Socket } from "node:net";
5
+ /**
6
+ * Error type for singleton/stale-socket violations.
7
+ */
8
+ export class UDSSingletonError extends Error {
9
+ context;
10
+ kind = "uds-singleton-error";
11
+ constructor(message, context) {
12
+ super(message);
13
+ this.context = context;
14
+ this.name = "UDSSingletonError";
15
+ }
16
+ }
17
+ /**
18
+ * Attempt to connect to an existing Unix domain socket to detect a live daemon.
19
+ *
20
+ * Returns "live" if a connection succeeds (live daemon).
21
+ * Returns "stale" if connection is refused/times out (socket file exists but no daemon).
22
+ * Returns "available" if the socket file does not exist.
23
+ *
24
+ * @param socketPath Absolute path to the UDS socket
25
+ * @param timeoutMs How long to wait before considering a connection attempt stale
26
+ */
27
+ export async function probeSocketLiveness(socketPath, timeoutMs = 500) {
28
+ // Socket file does not exist — path is available
29
+ if (!existsSync(socketPath)) {
30
+ return "available";
31
+ }
32
+ // Socket file exists; try connecting to detect a live daemon
33
+ return new Promise((resolve) => {
34
+ const socket = new Socket();
35
+ let resolved = false;
36
+ const timeout = setTimeout(() => {
37
+ if (!resolved) {
38
+ resolved = true;
39
+ socket.destroy();
40
+ resolve("stale");
41
+ }
42
+ }, timeoutMs);
43
+ socket.on("connect", () => {
44
+ if (!resolved) {
45
+ resolved = true;
46
+ clearTimeout(timeout);
47
+ socket.destroy();
48
+ resolve("live");
49
+ }
50
+ });
51
+ socket.on("error", () => {
52
+ if (!resolved) {
53
+ resolved = true;
54
+ clearTimeout(timeout);
55
+ resolve("stale");
56
+ }
57
+ });
58
+ socket.connect({ path: socketPath });
59
+ });
60
+ }
61
+ /**
62
+ * Returns true if a process with the given pid is currently alive.
63
+ *
64
+ * Uses the `kill(pid, 0)` idiom: no signal is actually delivered, but the
65
+ * OS still validates that a process with this pid exists and reports
66
+ * permission errors for processes that exist but are owned by another
67
+ * user (EPERM means "alive, just not signalable by us" — still alive).
68
+ */
69
+ function isProcessAlive(pid) {
70
+ if (!Number.isInteger(pid) || pid <= 0)
71
+ return false;
72
+ try {
73
+ process.kill(pid, 0);
74
+ return true;
75
+ }
76
+ catch (error) {
77
+ return error.code === "EPERM";
78
+ }
79
+ }
80
+ /**
81
+ * Best-effort read of the holder recorded in a lock file. Returns undefined
82
+ * if the file is missing, unreadable, or does not contain a well-formed
83
+ * holder record — callers must treat that as "unknown holder" and never
84
+ * infer liveness from it.
85
+ */
86
+ async function readLockHolder(lockPath) {
87
+ try {
88
+ const content = await readFile(lockPath, "utf8");
89
+ const parsed = JSON.parse(content);
90
+ if (parsed &&
91
+ typeof parsed === "object" &&
92
+ typeof parsed.pid === "number" &&
93
+ typeof parsed.startedAt === "number") {
94
+ return parsed;
95
+ }
96
+ return undefined;
97
+ }
98
+ catch {
99
+ return undefined;
100
+ }
101
+ }
102
+ /**
103
+ * Attempt to exclusively create the lock file in one atomic step (open with
104
+ * the `wx` flag: O_CREAT | O_EXCL). Unlike rename-based "locking", this
105
+ * fails with EEXIST when the destination already exists instead of
106
+ * silently replacing it — exactly one concurrent caller can ever succeed
107
+ * for a given lockPath.
108
+ *
109
+ * Returns true if this call created the file (lock acquired), false if the
110
+ * file already existed (EEXIST). Any other error propagates.
111
+ */
112
+ async function tryCreateLockFile(lockPath, pid) {
113
+ let handle;
114
+ try {
115
+ handle = await open(lockPath, "wx");
116
+ }
117
+ catch (error) {
118
+ if (error.code === "EEXIST") {
119
+ return false;
120
+ }
121
+ throw error;
122
+ }
123
+ try {
124
+ const payload = { pid, startedAt: Date.now() };
125
+ await handle.writeFile(JSON.stringify(payload), "utf8");
126
+ return true;
127
+ }
128
+ finally {
129
+ await handle.close();
130
+ }
131
+ }
132
+ /**
133
+ * Acquire a lock via exclusive file creation (fs `wx` open), with
134
+ * dead-holder reclamation and retry.
135
+ *
136
+ * Only one concurrent caller can ever hold the lock: the underlying
137
+ * `open(lockPath, "wx")` is an atomic O_CREAT|O_EXCL create that fails with
138
+ * EEXIST for every caller after the first, unlike a rename-based scheme
139
+ * (which silently replaces the destination and lets every racer "succeed").
140
+ *
141
+ * On EEXIST, the recorded holder's pid is liveness-checked via
142
+ * `process.kill(pid, 0)`: a dead holder's lock file is reclaimed (unlinked)
143
+ * and creation is retried once immediately; a live holder means the lock is
144
+ * genuinely held elsewhere, so this attempt waits out the retry interval
145
+ * and tries again until timeoutMs elapses.
146
+ *
147
+ * @param lockPath Path to the lock file
148
+ * @param timeoutMs How long to wait
149
+ * @param pid Process ID to write into the lock file
150
+ */
151
+ export async function acquireLock(lockPath, timeoutMs, pid) {
152
+ const lockDir = dirname(lockPath);
153
+ const startTime = Date.now();
154
+ const retryIntervalMs = 10;
155
+ // Ensure lock directory exists
156
+ try {
157
+ await mkdir(lockDir, { recursive: true });
158
+ }
159
+ catch {
160
+ // Directory may already exist
161
+ }
162
+ while (Date.now() - startTime < timeoutMs) {
163
+ if (await tryCreateLockFile(lockPath, pid)) {
164
+ return true;
165
+ }
166
+ // Lock file exists (EEXIST). Inspect the holder before deciding to wait.
167
+ const holder = await readLockHolder(lockPath);
168
+ if (holder && !isProcessAlive(holder.pid)) {
169
+ // Stale lock left by a dead process: reclaim it and retry immediately,
170
+ // once, before falling back to the normal wait-and-retry loop.
171
+ try {
172
+ await unlink(lockPath);
173
+ }
174
+ catch {
175
+ // Another process may have already reclaimed/removed it; fall
176
+ // through to the retry loop below.
177
+ }
178
+ if (await tryCreateLockFile(lockPath, pid)) {
179
+ return true;
180
+ }
181
+ }
182
+ await new Promise((resolve) => setTimeout(resolve, retryIntervalMs));
183
+ }
184
+ return false; // Timeout reached
185
+ }
186
+ /**
187
+ * Atomically probe and recover from a stale socket using a lock file for race safety.
188
+ *
189
+ * On the first call (or after lock acquisition), probes for a live daemon:
190
+ * - If live: returns "live" immediately (this process should exit)
191
+ * - If stale: unlinks the socket file and returns "stale-recovered"
192
+ * - If available: returns "available" immediately
193
+ *
194
+ * The lock file (at `<socketPath>.lock`) ensures only one process enters the
195
+ * probe-unlink-bind critical section at a time. Two simultaneous spawners will
196
+ * race to acquire the lock; the winner probes/unlinks/binds, the loser re-probes
197
+ * and discovers "live" (sees the winner's bound socket) and exits.
198
+ *
199
+ * @param socketPath Absolute path to the UDS socket
200
+ * @param lockTimeoutMs How long to wait acquiring the lock file
201
+ * @returns "live" (other daemon won the race), "stale-recovered" (unlinked stale socket), or "available" (free to bind)
202
+ * @throws UDSSingletonError if lock acquisition fails or other I/O errors
203
+ */
204
+ export async function probeAndRecoverWithLock(socketPath, lockTimeoutMs = 1000) {
205
+ const lockPath = `${socketPath}.lock`;
206
+ // Try to acquire lock
207
+ const lockAcquired = await acquireLock(lockPath, lockTimeoutMs, process.pid);
208
+ if (!lockAcquired) {
209
+ // Lock acquisition timed out; re-probe to see if another process succeeded
210
+ const probeResult = await probeSocketLiveness(socketPath);
211
+ if (probeResult === "live") {
212
+ return "live";
213
+ }
214
+ // Stale or available; try again (may get lock next time)
215
+ throw new UDSSingletonError(`Failed to acquire lock on ${lockPath} within ${lockTimeoutMs}ms; socket state is ${probeResult}`);
216
+ }
217
+ try {
218
+ // Locked section: probe for a live daemon
219
+ const probeResult = await probeSocketLiveness(socketPath);
220
+ if (probeResult === "live") {
221
+ // Another daemon owns this socket
222
+ return "live";
223
+ }
224
+ if (probeResult === "stale") {
225
+ // Socket file is stale; unlink it so bind() can proceed
226
+ try {
227
+ await unlink(socketPath);
228
+ }
229
+ catch {
230
+ // May have already been unlinked by another process; safe to ignore
231
+ }
232
+ return "stale-recovered";
233
+ }
234
+ // Socket path is free
235
+ return "available";
236
+ }
237
+ finally {
238
+ // Release lock by deleting the lock file
239
+ try {
240
+ await unlink(lockPath);
241
+ }
242
+ catch {
243
+ // Lock file cleanup failures are non-fatal
244
+ }
245
+ }
246
+ }
247
+ /**
248
+ * Ensure the daemon can bind to the given socket path, handling stale sockets.
249
+ *
250
+ * This is the high-level entry point for singleton + stale-socket recovery.
251
+ * Call this before binding to a UDS socket in your server:
252
+ *
253
+ * ```
254
+ * const result = await ensureSocketBindability(socketPath);
255
+ * if (result === "live") {
256
+ * // Another daemon already owns this socket; exit gracefully
257
+ * process.exit(0);
258
+ * }
259
+ * // result is "stale-recovered" or "available"; proceed with binding
260
+ * ```
261
+ *
262
+ * @param socketPath Absolute path to the UDS socket
263
+ * @returns "live" if another daemon owns the socket (exit immediately),
264
+ * or "stale-recovered"/"available" (safe to bind)
265
+ * @throws UDSSingletonError on unrecoverable errors (lock acquisition, etc.)
266
+ */
267
+ export async function ensureSocketBindability(socketPath) {
268
+ return probeAndRecoverWithLock(socketPath);
269
+ }
270
+ function isAddrInUseError(error) {
271
+ return (typeof error === "object" &&
272
+ error !== null &&
273
+ "code" in error &&
274
+ error.code === "EADDRINUSE");
275
+ }
276
+ /**
277
+ * Probe, recover from a stale socket, and invoke `bind()` — all inside the
278
+ * single critical section held by the `<socketPath>.lock` file.
279
+ *
280
+ * This closes the race the two-step "check bindability, then bind()
281
+ * separately" pattern leaves open: between releasing the probe/unlink lock
282
+ * and actually calling `bind()`, another process could probe the same
283
+ * now-empty path, also decide it is free, and race this one to `bind()`.
284
+ * Here `bind()` itself runs while the lock is still held, so at most one
285
+ * process per lock ever reaches it while the path is in the
286
+ * stale/available state.
287
+ *
288
+ * `bind()` is also wrapped in try/catch: if it still throws EADDRINUSE
289
+ * (e.g. a process outside this locking scheme bound the path, or a timing
290
+ * gap before the lock was acquired), the live owner is re-probed — if it
291
+ * responds, this process reports "already-served" instead of crashing
292
+ * uncaught; if it does not respond, the socket is stale, and `bind()` is
293
+ * retried exactly once after unlinking it.
294
+ *
295
+ * @param socketPath Absolute path to the UDS socket
296
+ * @param bind Callback that performs the actual bind (e.g. `() => Bun.serve(...)`)
297
+ * @param lockTimeoutMs How long to wait acquiring the lock file
298
+ */
299
+ export async function bindUnixSocketSingleton(socketPath, bind, lockTimeoutMs = 1000) {
300
+ const lockPath = `${socketPath}.lock`;
301
+ const lockAcquired = await acquireLock(lockPath, lockTimeoutMs, process.pid);
302
+ if (!lockAcquired) {
303
+ // Could not get the bind lock in time; check whether someone else is
304
+ // actually serving before giving up entirely.
305
+ const probeResult = await probeSocketLiveness(socketPath);
306
+ if (probeResult === "live") {
307
+ return { kind: "already-served" };
308
+ }
309
+ throw new UDSSingletonError(`Failed to acquire bind lock on ${lockPath} within ${lockTimeoutMs}ms; socket state is ${probeResult}`);
310
+ }
311
+ try {
312
+ return await bindWithRecovery(socketPath, bind);
313
+ }
314
+ finally {
315
+ try {
316
+ await unlink(lockPath);
317
+ }
318
+ catch {
319
+ // Lock file cleanup failures are non-fatal.
320
+ }
321
+ }
322
+ }
323
+ async function bindWithRecovery(socketPath, bind) {
324
+ const initialProbe = await probeSocketLiveness(socketPath);
325
+ if (initialProbe === "live") {
326
+ return { kind: "already-served" };
327
+ }
328
+ if (initialProbe === "stale") {
329
+ try {
330
+ await unlink(socketPath);
331
+ }
332
+ catch {
333
+ // May already be gone.
334
+ }
335
+ }
336
+ try {
337
+ const server = await bind();
338
+ return { kind: "bound", server };
339
+ }
340
+ catch (error) {
341
+ if (!isAddrInUseError(error)) {
342
+ throw error;
343
+ }
344
+ // Something bound the path between our probe and our bind() attempt.
345
+ const raceProbe = await probeSocketLiveness(socketPath);
346
+ if (raceProbe === "live") {
347
+ return { kind: "already-served" };
348
+ }
349
+ // Stale socket file left behind by a process that died mid-race;
350
+ // reclaim it and retry exactly once.
351
+ try {
352
+ await unlink(socketPath);
353
+ }
354
+ catch {
355
+ // May already be gone.
356
+ }
357
+ const server = await bind();
358
+ return { kind: "bound", server };
359
+ }
360
+ }
package/dist/refs.d.ts ADDED
@@ -0,0 +1,15 @@
1
+ export interface ParsedNamedRef {
2
+ readonly pluginPrefix: string | undefined;
3
+ readonly name: string;
4
+ }
5
+ export interface ParsedSpaceItemRef {
6
+ readonly pluginPrefix: string | undefined;
7
+ readonly space: string;
8
+ readonly name: string;
9
+ }
10
+ export interface RegistryWithDeps<TSelf> {
11
+ readonly deps: ReadonlyMap<string, TSelf>;
12
+ }
13
+ export declare const parseNamedRef: (ref: string) => ParsedNamedRef;
14
+ export declare const parseSpaceItemRef: (ref: string, separator: "/" | "#") => ParsedSpaceItemRef | undefined;
15
+ export declare const registryForRef: <TRegistry extends RegistryWithDeps<TRegistry>>(ref: string, registry: TRegistry) => TRegistry | undefined;
package/dist/refs.js ADDED
@@ -0,0 +1,26 @@
1
+ export const parseNamedRef = (ref) => {
2
+ const colon = ref.indexOf(":");
3
+ if (colon === -1)
4
+ return { pluginPrefix: undefined, name: ref };
5
+ return {
6
+ pluginPrefix: ref.slice(0, colon),
7
+ name: ref.slice(colon + 1),
8
+ };
9
+ };
10
+ export const parseSpaceItemRef = (ref, separator) => {
11
+ const parsed = parseNamedRef(ref);
12
+ const split = parsed.name.indexOf(separator);
13
+ if (split === -1)
14
+ return undefined;
15
+ const space = parsed.name.slice(0, split);
16
+ const name = parsed.name.slice(split + 1);
17
+ if (space.length === 0 || name.length === 0)
18
+ return undefined;
19
+ return { pluginPrefix: parsed.pluginPrefix, space, name };
20
+ };
21
+ export const registryForRef = (ref, registry) => {
22
+ const parsed = parseNamedRef(ref);
23
+ if (!parsed.pluginPrefix)
24
+ return registry;
25
+ return registry.deps.get(parsed.pluginPrefix);
26
+ };
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Snapshot manifest — the ONLY persistent state the sync engine keeps per
3
+ * harness root (docs/overhaul-one-writer-plan.md, WS4). It replaces all
4
+ * per-harness managed ledgers.
5
+ *
6
+ * The manifest is a disposable cache, never the source of truth: it exists
7
+ * solely to answer "may I skip this write?" and "what do I prune?". Deleting
8
+ * it must always converge on the next refresh (state-is-disposable gate).
9
+ *
10
+ * Identity discipline: entries are keyed by `targetPath` alone within a
11
+ * manifest (one manifest per harness root). Artifact kinds, source paths,
12
+ * and scopes are never identity — that scheme is what produced 646 zombie
13
+ * duplicate ledger entries. `plugin` is diagnostic attribution only.
14
+ *
15
+ * Determinism discipline: the manifest carries no timestamps and no run ids,
16
+ * and entries are sorted by `targetPath` on commit — a converged run leaves
17
+ * the manifest byte-identical.
18
+ */
19
+ import { Schema } from "effect";
20
+ export declare const SNAPSHOT_MANIFEST_VERSION: 1;
21
+ /**
22
+ * How the sync engine owns the entry's target:
23
+ * - `owned`: a whole file Prism owns outright (rebuild/prune authority).
24
+ * - `region`: a prism-named fragment inside a shared user file; `regionKey`
25
+ * names the fragment and `contentHash` hashes the rendered fragment, not
26
+ * the whole file.
27
+ */
28
+ export declare const SnapshotEntryMode: Schema.Literal<["owned", "region"]>;
29
+ export type SnapshotEntryMode = typeof SnapshotEntryMode.Type;
30
+ export declare const SnapshotEntrySchema: Schema.Struct<{
31
+ targetPath: typeof Schema.String;
32
+ contentHash: typeof Schema.String;
33
+ mode: Schema.Literal<["owned", "region"]>;
34
+ regionKey: Schema.optional<typeof Schema.String>;
35
+ plugin: typeof Schema.String;
36
+ }>;
37
+ export type SnapshotEntry = typeof SnapshotEntrySchema.Type;
38
+ /**
39
+ * Versioned union. Future versions join this union; readers decode the union
40
+ * and migrate forward in code. There is exactly one version today.
41
+ */
42
+ export declare const SnapshotManifestSchema: Schema.Struct<{
43
+ version: Schema.Literal<[1]>;
44
+ harness: typeof Schema.String;
45
+ root: typeof Schema.String;
46
+ entries: Schema.Array$<Schema.Struct<{
47
+ targetPath: typeof Schema.String;
48
+ contentHash: typeof Schema.String;
49
+ mode: Schema.Literal<["owned", "region"]>;
50
+ regionKey: Schema.optional<typeof Schema.String>;
51
+ plugin: typeof Schema.String;
52
+ }>>;
53
+ }>;
54
+ export type SnapshotManifest = typeof SnapshotManifestSchema.Type;
55
+ export declare const decodeSnapshotManifest: (u: unknown, overrideOptions?: import("effect/SchemaAST").ParseOptions) => import("effect/Either").Either<{
56
+ readonly entries: readonly {
57
+ readonly plugin: string;
58
+ readonly targetPath: string;
59
+ readonly contentHash: string;
60
+ readonly mode: "owned" | "region";
61
+ readonly regionKey?: string | undefined;
62
+ }[];
63
+ readonly version: 1;
64
+ readonly harness: string;
65
+ readonly root: string;
66
+ }, import("effect/ParseResult").ParseError>;
67
+ declare const decodeSnapshotManifestUnknown: (u: unknown, overrideOptions?: import("effect/SchemaAST").ParseOptions) => import("effect/Either").Either<{
68
+ readonly entries: readonly {
69
+ readonly plugin: string;
70
+ readonly targetPath: string;
71
+ readonly contentHash: string;
72
+ readonly mode: "owned" | "region";
73
+ readonly regionKey?: string | undefined;
74
+ }[];
75
+ readonly version: 1;
76
+ readonly harness: string;
77
+ readonly root: string;
78
+ }, import("effect/ParseResult").ParseError>;
79
+ /**
80
+ * Migrate a parsed snapshot manifest to the latest version.
81
+ *
82
+ * Today only version 1 exists, so this is the identity for valid v1 payloads.
83
+ * When a v2 format is introduced, this function will branch on `input.version`
84
+ * and transform older shapes forward before the final schema decode.
85
+ */
86
+ export declare const migrateSnapshotManifest: (input: unknown) => ReturnType<typeof decodeSnapshotManifestUnknown>;
87
+ export declare const encodeSnapshotManifest: (manifest: SnapshotManifest) => string;
88
+ export declare const emptySnapshotManifest: (options: {
89
+ readonly harness: string;
90
+ readonly root: string;
91
+ }) => SnapshotManifest;
92
+ export {};
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Snapshot manifest — the ONLY persistent state the sync engine keeps per
3
+ * harness root (docs/overhaul-one-writer-plan.md, WS4). It replaces all
4
+ * per-harness managed ledgers.
5
+ *
6
+ * The manifest is a disposable cache, never the source of truth: it exists
7
+ * solely to answer "may I skip this write?" and "what do I prune?". Deleting
8
+ * it must always converge on the next refresh (state-is-disposable gate).
9
+ *
10
+ * Identity discipline: entries are keyed by `targetPath` alone within a
11
+ * manifest (one manifest per harness root). Artifact kinds, source paths,
12
+ * and scopes are never identity — that scheme is what produced 646 zombie
13
+ * duplicate ledger entries. `plugin` is diagnostic attribution only.
14
+ *
15
+ * Determinism discipline: the manifest carries no timestamps and no run ids,
16
+ * and entries are sorted by `targetPath` on commit — a converged run leaves
17
+ * the manifest byte-identical.
18
+ */
19
+ import { Schema } from "effect";
20
+ export const SNAPSHOT_MANIFEST_VERSION = 1;
21
+ /**
22
+ * How the sync engine owns the entry's target:
23
+ * - `owned`: a whole file Prism owns outright (rebuild/prune authority).
24
+ * - `region`: a prism-named fragment inside a shared user file; `regionKey`
25
+ * names the fragment and `contentHash` hashes the rendered fragment, not
26
+ * the whole file.
27
+ */
28
+ export const SnapshotEntryMode = Schema.Literal("owned", "region");
29
+ export const SnapshotEntrySchema = Schema.Struct({
30
+ targetPath: Schema.String,
31
+ contentHash: Schema.String,
32
+ mode: SnapshotEntryMode,
33
+ regionKey: Schema.optional(Schema.String),
34
+ plugin: Schema.String,
35
+ });
36
+ const SnapshotManifestV1 = Schema.Struct({
37
+ version: Schema.Literal(SNAPSHOT_MANIFEST_VERSION),
38
+ harness: Schema.String,
39
+ root: Schema.String,
40
+ entries: Schema.Array(SnapshotEntrySchema),
41
+ });
42
+ /**
43
+ * Versioned union. Future versions join this union; readers decode the union
44
+ * and migrate forward in code. There is exactly one version today.
45
+ */
46
+ export const SnapshotManifestSchema = SnapshotManifestV1;
47
+ export const decodeSnapshotManifest = Schema.decodeUnknownEither(Schema.parseJson(SnapshotManifestSchema));
48
+ const decodeSnapshotManifestUnknown = Schema.decodeUnknownEither(SnapshotManifestSchema);
49
+ /**
50
+ * Migrate a parsed snapshot manifest to the latest version.
51
+ *
52
+ * Today only version 1 exists, so this is the identity for valid v1 payloads.
53
+ * When a v2 format is introduced, this function will branch on `input.version`
54
+ * and transform older shapes forward before the final schema decode.
55
+ */
56
+ export const migrateSnapshotManifest = (input) => {
57
+ // Future migration hook: inspect input.version and apply v2/v3/etc. forward transforms.
58
+ return decodeSnapshotManifestUnknown(input);
59
+ };
60
+ export const encodeSnapshotManifest = (manifest) => {
61
+ const sorted = {
62
+ ...manifest,
63
+ entries: [...manifest.entries].sort((left, right) => left.targetPath === right.targetPath
64
+ ? (left.regionKey ?? "").localeCompare(right.regionKey ?? "")
65
+ : left.targetPath.localeCompare(right.targetPath)),
66
+ };
67
+ return `${JSON.stringify(sorted, null, 2)}\n`;
68
+ };
69
+ export const emptySnapshotManifest = (options) => ({
70
+ version: SNAPSHOT_MANIFEST_VERSION,
71
+ harness: options.harness,
72
+ root: options.root,
73
+ entries: [],
74
+ });
@@ -0,0 +1,8 @@
1
+ export type StableJsonValue = null | boolean | number | string | readonly StableJsonValue[] | {
2
+ readonly [key: string]: StableJsonValue | undefined;
3
+ };
4
+ export declare const compareCodePoint: (left: string, right: string) => number;
5
+ export declare const sortStableStrings: <T extends string>(values: Iterable<T>) => T[];
6
+ export declare const stableJsonValue: (value: StableJsonValue) => StableJsonValue;
7
+ export declare const stableJsonStringify: (value: StableJsonValue) => string;
8
+ export declare const stableJsonHash: (value: StableJsonValue) => string;
@@ -0,0 +1,27 @@
1
+ import { createHash } from "node:crypto";
2
+ export const compareCodePoint = (left, right) => {
3
+ const normalizedLeft = left.normalize("NFC");
4
+ const normalizedRight = right.normalize("NFC");
5
+ if (normalizedLeft === normalizedRight)
6
+ return 0;
7
+ return normalizedLeft < normalizedRight ? -1 : 1;
8
+ };
9
+ export const sortStableStrings = (values) => [...values].sort(compareCodePoint);
10
+ export const stableJsonValue = (value) => {
11
+ if (Array.isArray(value)) {
12
+ return value.map((item) => stableJsonValue(item));
13
+ }
14
+ if (value && typeof value === "object") {
15
+ const record = value;
16
+ const sorted = {};
17
+ for (const key of sortStableStrings(Object.keys(record))) {
18
+ const entry = record[key];
19
+ if (entry !== undefined)
20
+ sorted[key] = stableJsonValue(entry);
21
+ }
22
+ return sorted;
23
+ }
24
+ return value;
25
+ };
26
+ export const stableJsonStringify = (value) => JSON.stringify(stableJsonValue(value));
27
+ export const stableJsonHash = (value) => createHash("sha256").update(stableJsonStringify(value)).digest("hex");
package/package.json ADDED
@@ -0,0 +1,73 @@
1
+ {
2
+ "name": "@skastr0/prism-sdk",
3
+ "version": "0.3.0",
4
+ "type": "module",
5
+ "description": "Core Prism contracts, refs, stable JSON, and manifest codecs for Prism runtimes and workflow tooling.",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/skastr0/prism.git",
10
+ "directory": "packages/prism-sdk"
11
+ },
12
+ "homepage": "https://github.com/skastr0/prism#readme",
13
+ "bugs": {
14
+ "url": "https://github.com/skastr0/prism/issues"
15
+ },
16
+ "keywords": [
17
+ "prism",
18
+ "ai-coding",
19
+ "agents",
20
+ "workflows",
21
+ "manifest"
22
+ ],
23
+ "publishConfig": {
24
+ "access": "public"
25
+ },
26
+ "sideEffects": false,
27
+ "exports": {
28
+ "./compile-manifest": {
29
+ "types": "./dist/compile-manifest.d.ts",
30
+ "import": "./dist/compile-manifest.js"
31
+ },
32
+ "./refs": {
33
+ "types": "./dist/refs.d.ts",
34
+ "import": "./dist/refs.js"
35
+ },
36
+ "./snapshot": {
37
+ "types": "./dist/snapshot.d.ts",
38
+ "import": "./dist/snapshot.js"
39
+ },
40
+ "./stable-json": {
41
+ "types": "./dist/stable-json.d.ts",
42
+ "import": "./dist/stable-json.js"
43
+ },
44
+ "./mcp/shim": {
45
+ "types": "./dist/mcp/shim.d.ts",
46
+ "import": "./dist/mcp/shim.js"
47
+ },
48
+ "./mcp/uds-registry": {
49
+ "types": "./dist/mcp/uds-registry.d.ts",
50
+ "import": "./dist/mcp/uds-registry.js"
51
+ },
52
+ "./mcp/uds-singleton": {
53
+ "types": "./dist/mcp/uds-singleton.d.ts",
54
+ "import": "./dist/mcp/uds-singleton.js"
55
+ }
56
+ },
57
+ "files": [
58
+ "dist",
59
+ "README.md",
60
+ "LICENSE"
61
+ ],
62
+ "scripts": {
63
+ "build": "tsc -p tsconfig.json",
64
+ "prepack": "bun run build"
65
+ },
66
+ "engines": {
67
+ "node": ">=18"
68
+ },
69
+ "dependencies": {
70
+ "effect": "^3.21.1",
71
+ "@modelcontextprotocol/sdk": "1.29.0"
72
+ }
73
+ }