synomem 0.9.0 → 0.9.2

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/src/resolvers.ts CHANGED
@@ -8,7 +8,18 @@
8
8
  * each other's attribution. The resolver narrows (pins a context, restricts to a
9
9
  * preset); the server alone authorizes.
10
10
  */
11
- import { createHash } from 'node:crypto';
11
+ import { randomBytes } from 'node:crypto';
12
+ import {
13
+ closeSync,
14
+ existsSync,
15
+ openSync,
16
+ readFileSync,
17
+ rmSync,
18
+ statSync,
19
+ writeSync,
20
+ } from 'node:fs';
21
+ import { join } from 'node:path';
22
+ import { atomicWriteFile } from './fs-utils.js';
12
23
  import { SynomemClient } from './client.js';
13
24
  import { describeIdentity, discoverContexts } from './discover.js';
14
25
  import { SynomemError } from './errors.js';
@@ -68,15 +79,90 @@ function required(): SynomemError {
68
79
  }
69
80
 
70
81
  /**
71
- * Stable local context id: derived from the store's persistent workspace id and the
72
- * canonical actor — never from a display name — so it survives renames and moving the
73
- * store, and an unrelated store with a same-named agent gets a different id.
82
+ * The stable local context id for one actor in one store, from the store's own
83
+ * context registry (`<home>/contexts.json`).
84
+ *
85
+ * The id is cryptographically random (128 bits) and minted the first time the
86
+ * actor is addressed, never derived from a name or from the canonical tuple:
87
+ * an id reveals nothing about its target and cannot be guessed. It survives
88
+ * renames and moving the store (the registry moves with it), and an unrelated
89
+ * store with a same-named agent has its own registry and so its own id.
90
+ * Minting is serialized across processes so two harnesses opening the store
91
+ * at once agree on one id.
74
92
  */
75
- export function localContextId(storeWorkspaceId: string, actor: ActorIdentity): string {
76
- const digest = createHash('sha256')
77
- .update(`${storeWorkspaceId}\0${actor.kind}\0${actor.id}`)
78
- .digest('hex');
79
- return `lctx_${digest.slice(0, 24)}`;
93
+ export function localContextId(home: string, actor: ActorIdentity): string {
94
+ const path = join(home, 'contexts.json');
95
+ const key = `${actor.kind}:${actor.id}`;
96
+ const existing = readRegistry(path).contexts[key];
97
+ if (existing) return existing;
98
+ return withRegistryLock(home, () => {
99
+ const registry = readRegistry(path);
100
+ const current = registry.contexts[key];
101
+ if (current) return current;
102
+ const id = `lctx_${randomBytes(16).toString('hex')}`;
103
+ registry.contexts[key] = id;
104
+ atomicWriteFile(path, `${JSON.stringify(registry, null, 2)}\n`, 0o600);
105
+ return id;
106
+ });
107
+ }
108
+
109
+ interface ContextRegistry {
110
+ version: 1;
111
+ contexts: Record<string, string>;
112
+ }
113
+
114
+ function readRegistry(path: string): ContextRegistry {
115
+ if (!existsSync(path)) return { version: 1, contexts: {} };
116
+ try {
117
+ const parsed = JSON.parse(readFileSync(path, 'utf8')) as Partial<ContextRegistry>;
118
+ if (parsed.version === 1 && parsed.contexts && typeof parsed.contexts === 'object') {
119
+ return { version: 1, contexts: { ...parsed.contexts } };
120
+ }
121
+ } catch {
122
+ /* fall through */
123
+ }
124
+ throw new SynomemError('CONFIG_INVALID', `${path} is not a valid Synomem context registry.`);
125
+ }
126
+
127
+ /** A short synchronous O_EXCL lock; a dead or old owner is broken. */
128
+ function withRegistryLock<T>(home: string, operation: () => T): T {
129
+ const lock = join(home, 'contexts.json.lock');
130
+ const deadline = Date.now() + 10_000;
131
+ const sleeper = new Int32Array(new SharedArrayBuffer(4));
132
+ for (;;) {
133
+ try {
134
+ const descriptor = openSync(lock, 'wx', 0o600);
135
+ writeSync(descriptor, `${process.pid}\n`);
136
+ closeSync(descriptor);
137
+ break;
138
+ } catch (error) {
139
+ if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error;
140
+ try {
141
+ const pid = Number(readFileSync(lock, 'utf8').trim());
142
+ const old = Date.now() - statSync(lock).mtimeMs > 5_000;
143
+ let alive = Number.isSafeInteger(pid) && pid > 0;
144
+ if (alive) {
145
+ try {
146
+ process.kill(pid, 0);
147
+ } catch (probe) {
148
+ alive = (probe as NodeJS.ErrnoException).code === 'EPERM';
149
+ }
150
+ }
151
+ if (!alive || old) rmSync(lock, { force: true });
152
+ } catch {
153
+ /* released meanwhile */
154
+ }
155
+ if (Date.now() > deadline) {
156
+ throw new SynomemError('DATABASE_BUSY', 'The local context registry is locked. Try again.');
157
+ }
158
+ Atomics.wait(sleeper, 0, 0, 20);
159
+ }
160
+ }
161
+ try {
162
+ return operation();
163
+ } finally {
164
+ rmSync(lock, { force: true });
165
+ }
80
166
  }
81
167
 
82
168
  /**
@@ -240,7 +326,7 @@ export function createLocalResolver(
240
326
  return {
241
327
  service: client,
242
328
  context: {
243
- contextId: localContextId(capabilities.binding.workspaceId, actor),
329
+ contextId: localContextId(options.home, actor),
244
330
  organizationId: null,
245
331
  workspaceId: capabilities.binding.workspaceId,
246
332
  actor,