@hardkas/core 0.2.2-alpha.1 → 0.3.0-alpha

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/dist/index.d.ts CHANGED
@@ -286,6 +286,126 @@ declare function maskSecrets(data: any): any;
286
286
  */
287
287
  declare function redactSecret(value: string): string;
288
288
 
289
+ /**
290
+ * Options for atomic file writing.
291
+ */
292
+ interface WriteFileAtomicOptions {
293
+ /** Encoding for string data (default: utf-8) */
294
+ encoding?: BufferEncoding;
295
+ /** File mode (permissions) */
296
+ mode?: number;
297
+ /** If true, calls fsync on the parent directory (Linux/macOS) */
298
+ fsyncParent?: boolean;
299
+ }
300
+ /**
301
+ * Writes a file atomically using the temp-file-and-rename pattern.
302
+ * Ensures that either the entire file is written or no changes are made.
303
+ *
304
+ * Pattern:
305
+ * 1. Write data to a temporary file in the same directory.
306
+ * 2. fsync the temporary file to ensure data is on disk.
307
+ * 3. Close the temporary file.
308
+ * 4. Rename the temporary file to the target path (atomic operation).
309
+ * 5. Optional: fsync the parent directory to ensure metadata is on disk.
310
+ */
311
+ declare function writeFileAtomic(targetPath: string, data: string | Buffer, options?: WriteFileAtomicOptions): Promise<void>;
312
+ /**
313
+ * Synchronous version of writeFileAtomic.
314
+ */
315
+ declare function writeFileAtomicSync(targetPath: string, data: string | Buffer, options?: WriteFileAtomicOptions): void;
316
+
317
+ /**
318
+ * HardKAS Corruption Taxonomy & Issue Reporting
319
+ */
320
+ type CorruptionCode = "ARTIFACT_JSON_INVALID" | "ARTIFACT_SCHEMA_MISSING" | "ARTIFACT_SCHEMA_INVALID" | "ARTIFACT_HASH_MISMATCH" | "ARTIFACT_ID_INVALID" | "ARTIFACT_LINEAGE_INVALID" | "EVENT_JSON_INVALID" | "EVENT_SCHEMA_INVALID" | "EVENT_LINE_CORRUPT" | "STORE_STALE" | "STORE_CORRUPT" | "STORE_REBUILD_REQUIRED" | "DUPLICATE_ARTIFACT" | "DUPLICATE_EVENT" | "SEMANTIC_DIVERGENCE" | "REPLAY_PARTIAL" | "REPLAY_UNSUPPORTED_CHECK" | "LOCK_HELD" | "LOCK_TIMEOUT" | "STALE_LOCK" | "LOCK_RELEASE_FAILED" | "LOCK_OWNER_MISMATCH" | "LOCK_METADATA_INVALID" | "STORE_MIGRATION_REQUIRED" | "STORE_MIGRATION_FAILED" | "STORE_MIGRATION_CHECKSUM_MISMATCH" | "STORE_SCHEMA_UNSUPPORTED" | "STORE_LEGACY_BOOTSTRAPPED";
321
+ type CorruptionSeverity = "warning" | "error";
322
+ interface CorruptionIssue {
323
+ readonly code: CorruptionCode;
324
+ readonly severity: CorruptionSeverity;
325
+ readonly message: string;
326
+ readonly path?: string;
327
+ readonly lineNumber?: number;
328
+ readonly artifactId?: string;
329
+ readonly contentHash?: string;
330
+ readonly suggestion?: string;
331
+ }
332
+ /**
333
+ * Formats a corruption issue for human-readable output.
334
+ */
335
+ declare function formatCorruptionIssue(issue: CorruptionIssue): string;
336
+
337
+ /**
338
+ * HardKAS Lock Metadata schema v1
339
+ */
340
+ interface LockMetadata {
341
+ schema: "hardkas.lock.v1";
342
+ name: string;
343
+ pid: number;
344
+ command: string;
345
+ cwd: string;
346
+ hostname: string;
347
+ createdAt: string;
348
+ expiresAt: string | null;
349
+ }
350
+ interface LockHandle {
351
+ readonly path: string;
352
+ readonly metadata: LockMetadata;
353
+ release(): Promise<void>;
354
+ }
355
+ interface AcquireLockArgs {
356
+ rootDir: string;
357
+ name: string;
358
+ command?: string;
359
+ staleMs?: number;
360
+ wait?: boolean;
361
+ timeoutMs?: number;
362
+ pollMs?: number;
363
+ }
364
+ /**
365
+ * Deterministic lock ordering to avoid deadlocks.
366
+ * workspace > node > accounts > artifacts > events > query-store
367
+ */
368
+ declare const LOCK_ORDER: string[];
369
+ /**
370
+ * Acquires a named lock for the workspace.
371
+ */
372
+ declare function acquireLock(args: AcquireLockArgs): Promise<LockHandle>;
373
+ /**
374
+ * Helper to run a task with a single lock.
375
+ */
376
+ declare function withLock<T>(args: AcquireLockArgs, fn: (handle: LockHandle) => Promise<T>): Promise<T>;
377
+ /**
378
+ * Helper to run a task with multiple locks in deterministic order.
379
+ */
380
+ declare function withLocks<T>(rootDir: string, names: string[], fn: () => Promise<T>, options?: {
381
+ command?: string;
382
+ wait?: boolean;
383
+ timeoutMs?: number;
384
+ }): Promise<T>;
385
+ /**
386
+ * Checks if a process is alive.
387
+ */
388
+ declare function isProcessAlive(pid: number): boolean;
389
+ /**
390
+ * Lists all active locks in the workspace.
391
+ */
392
+ declare function listLocks(rootDir: string): Array<{
393
+ name: string;
394
+ metadata: LockMetadata;
395
+ path: string;
396
+ isAlive: boolean;
397
+ }>;
398
+ /**
399
+ * Safely clears a lock if criteria are met.
400
+ */
401
+ declare function clearLock(rootDir: string, name: string, options?: {
402
+ force?: boolean;
403
+ ifDead?: boolean;
404
+ }): {
405
+ cleared: boolean;
406
+ reason?: string;
407
+ };
408
+
289
409
  declare const SOMPI_PER_KAS = 100000000n;
290
410
  declare const kaspaNetworkIdSchema: z.ZodEnum<["mainnet", "testnet-10", "testnet-11", "testnet-12", "simnet", "simnet-1", "devnet"]>;
291
411
  type NetworkId = Brand<z.infer<typeof kaspaNetworkIdSchema>, "NetworkId">;
@@ -376,4 +496,4 @@ declare function parseHardkasConfig(input: unknown): HardkasConfig;
376
496
  declare function parseKasToSompi(input: string): bigint;
377
497
  declare function formatSompi(amountSompi: bigint): string;
378
498
 
379
- export { type ArtifactId, type ArtifactType, ArtifactTypeSchema, type Brand, type Branded, type ContentHash, type CoreEvent, type CoreEventListener, type CorrelationId, type DaaScore, type EventDomain, type EventEnvelope, type EventId, type EventKind, type EventPayloadByKind, type EventSequence, type ExecutionMode, ExecutionModeSchema, type HardkasConfig, HardkasError, type InvariantDomain, type InvariantSeverity, InvariantViolationError, type KaspaAddress, type LineageId, type NetworkId, NetworkIdSchema, type RpcEndpointId, SOMPI_PER_KAS, type StampedEvent, type TxId, type UnknownEventPayload, type WorkflowId, artifactTypeSchema, asArtifactId, asContentHash, asCorrelationId, asDaaScore, asEventId, asEventSequence, asKaspaAddress, asLineageId, asNetworkId, asRpcEndpointId, asTxId, asWorkflowId, coreEvents, createEventEnvelope, executionModeSchema, formatSompi, hardkasConfigSchema, kaspaNetworkIdSchema, maskSecrets, parseHardkasConfig, parseKasToSompi, redactSecret, validateEventEnvelope };
499
+ export { type AcquireLockArgs, type ArtifactId, type ArtifactType, ArtifactTypeSchema, type Brand, type Branded, type ContentHash, type CoreEvent, type CoreEventListener, type CorrelationId, type CorruptionCode, type CorruptionIssue, type CorruptionSeverity, type DaaScore, type EventDomain, type EventEnvelope, type EventId, type EventKind, type EventPayloadByKind, type EventSequence, type ExecutionMode, ExecutionModeSchema, type HardkasConfig, HardkasError, type InvariantDomain, type InvariantSeverity, InvariantViolationError, type KaspaAddress, LOCK_ORDER, type LineageId, type LockHandle, type LockMetadata, type NetworkId, NetworkIdSchema, type RpcEndpointId, SOMPI_PER_KAS, type StampedEvent, type TxId, type UnknownEventPayload, type WorkflowId, type WriteFileAtomicOptions, acquireLock, artifactTypeSchema, asArtifactId, asContentHash, asCorrelationId, asDaaScore, asEventId, asEventSequence, asKaspaAddress, asLineageId, asNetworkId, asRpcEndpointId, asTxId, asWorkflowId, clearLock, coreEvents, createEventEnvelope, executionModeSchema, formatCorruptionIssue, formatSompi, hardkasConfigSchema, isProcessAlive, kaspaNetworkIdSchema, listLocks, maskSecrets, parseHardkasConfig, parseKasToSompi, redactSecret, validateEventEnvelope, withLock, withLocks, writeFileAtomic, writeFileAtomicSync };
package/dist/index.js CHANGED
@@ -111,6 +111,315 @@ function redactSecret(value) {
111
111
  return `${value.slice(0, 6)}...${value.slice(-4)}`;
112
112
  }
113
113
 
114
+ // src/fs.ts
115
+ import fs from "fs";
116
+ import path from "path";
117
+ async function writeFileAtomic(targetPath, data, options = {}) {
118
+ const dir = path.dirname(targetPath);
119
+ const base = path.basename(targetPath);
120
+ const tempPath = path.join(dir, `.tmp.${base}.${Math.random().toString(36).slice(2)}`);
121
+ let fd = null;
122
+ try {
123
+ if (!fs.existsSync(dir)) {
124
+ fs.mkdirSync(dir, { recursive: true });
125
+ }
126
+ fd = fs.openSync(tempPath, "w", options.mode);
127
+ const buffer = typeof data === "string" ? Buffer.from(data, options.encoding || "utf-8") : data;
128
+ fs.writeSync(fd, buffer, 0, buffer.length);
129
+ fs.fsyncSync(fd);
130
+ fs.closeSync(fd);
131
+ fd = null;
132
+ let attempts = 0;
133
+ const maxAttempts = process.platform === "win32" ? 5 : 1;
134
+ while (attempts < maxAttempts) {
135
+ try {
136
+ fs.renameSync(tempPath, targetPath);
137
+ break;
138
+ } catch (e) {
139
+ attempts++;
140
+ if (attempts >= maxAttempts) throw e;
141
+ if (e.code === "EPERM" || e.code === "EBUSY") {
142
+ await new Promise((resolve) => setTimeout(resolve, 10 * attempts));
143
+ continue;
144
+ }
145
+ throw e;
146
+ }
147
+ }
148
+ if (options.fsyncParent && process.platform !== "win32") {
149
+ let dirFd = null;
150
+ try {
151
+ dirFd = fs.openSync(dir, "r");
152
+ fs.fsyncSync(dirFd);
153
+ } catch (e) {
154
+ } finally {
155
+ if (dirFd !== null) fs.closeSync(dirFd);
156
+ }
157
+ }
158
+ } catch (err) {
159
+ throw new HardkasError(
160
+ "IO_ERROR",
161
+ `Failed to write file atomically: ${targetPath}`,
162
+ { cause: err }
163
+ );
164
+ } finally {
165
+ if (fs.existsSync(tempPath)) {
166
+ try {
167
+ fs.unlinkSync(tempPath);
168
+ } catch (e) {
169
+ }
170
+ }
171
+ if (fd !== null) {
172
+ try {
173
+ fs.closeSync(fd);
174
+ } catch (e) {
175
+ }
176
+ }
177
+ }
178
+ }
179
+ function writeFileAtomicSync(targetPath, data, options = {}) {
180
+ const dir = path.dirname(targetPath);
181
+ const base = path.basename(targetPath);
182
+ const tempPath = path.join(dir, `.tmp.${base}.${Math.random().toString(36).slice(2)}`);
183
+ let fd = null;
184
+ try {
185
+ if (!fs.existsSync(dir)) {
186
+ fs.mkdirSync(dir, { recursive: true });
187
+ }
188
+ fd = fs.openSync(tempPath, "w", options.mode);
189
+ const buffer = typeof data === "string" ? Buffer.from(data, options.encoding || "utf-8") : data;
190
+ fs.writeSync(fd, buffer, 0, buffer.length);
191
+ fs.fsyncSync(fd);
192
+ fs.closeSync(fd);
193
+ fd = null;
194
+ let attempts = 0;
195
+ const maxAttempts = process.platform === "win32" ? 5 : 1;
196
+ while (attempts < maxAttempts) {
197
+ try {
198
+ fs.renameSync(tempPath, targetPath);
199
+ break;
200
+ } catch (e) {
201
+ attempts++;
202
+ if (attempts >= maxAttempts) throw e;
203
+ if (e.code === "EPERM" || e.code === "EBUSY") {
204
+ continue;
205
+ }
206
+ throw e;
207
+ }
208
+ }
209
+ if (options.fsyncParent && process.platform !== "win32") {
210
+ let dirFd = null;
211
+ try {
212
+ dirFd = fs.openSync(dir, "r");
213
+ fs.fsyncSync(dirFd);
214
+ } catch (e) {
215
+ } finally {
216
+ if (dirFd !== null) fs.closeSync(dirFd);
217
+ }
218
+ }
219
+ } catch (err) {
220
+ throw new HardkasError(
221
+ "IO_ERROR",
222
+ `Failed to write file atomically (sync): ${targetPath}`,
223
+ { cause: err }
224
+ );
225
+ } finally {
226
+ if (fs.existsSync(tempPath)) {
227
+ try {
228
+ fs.unlinkSync(tempPath);
229
+ } catch (e) {
230
+ }
231
+ }
232
+ if (fd !== null) {
233
+ try {
234
+ fs.closeSync(fd);
235
+ } catch (e) {
236
+ }
237
+ }
238
+ }
239
+ }
240
+
241
+ // src/corruption.ts
242
+ function formatCorruptionIssue(issue) {
243
+ const parts = [];
244
+ const icon = issue.severity === "error" ? "\u274C" : "\u26A0\uFE0F";
245
+ parts.push(`${icon} [${issue.code}] ${issue.message}`);
246
+ if (issue.path) {
247
+ const loc = issue.lineNumber ? `${issue.path}:${issue.lineNumber}` : issue.path;
248
+ parts.push(` Location: ${loc}`);
249
+ }
250
+ if (issue.artifactId) {
251
+ parts.push(` Artifact: ${issue.artifactId}`);
252
+ }
253
+ if (issue.suggestion) {
254
+ parts.push(` Suggestion: ${issue.suggestion}`);
255
+ }
256
+ return parts.join("\n");
257
+ }
258
+
259
+ // src/lock.ts
260
+ import fs2 from "fs";
261
+ import path2 from "path";
262
+ import os from "os";
263
+ var LOCK_ORDER = [
264
+ "workspace",
265
+ "node",
266
+ "accounts",
267
+ "artifacts",
268
+ "events",
269
+ "query-store"
270
+ ];
271
+ async function acquireLock(args) {
272
+ const lockDir = path2.join(args.rootDir, ".hardkas", "locks");
273
+ const lockPath = path2.join(lockDir, `${args.name}.lock`);
274
+ const timeoutMs = args.timeoutMs ?? 3e4;
275
+ const pollMs = args.pollMs ?? 250;
276
+ const start = Date.now();
277
+ if (!fs2.existsSync(lockDir)) {
278
+ fs2.mkdirSync(lockDir, { recursive: true });
279
+ }
280
+ while (true) {
281
+ try {
282
+ const metadata = {
283
+ schema: "hardkas.lock.v1",
284
+ name: args.name,
285
+ pid: process.pid,
286
+ command: args.command || process.argv.join(" "),
287
+ cwd: process.cwd(),
288
+ hostname: os.hostname(),
289
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
290
+ expiresAt: null
291
+ };
292
+ const fd = fs2.openSync(lockPath, "wx");
293
+ fs2.writeSync(fd, JSON.stringify(metadata, null, 2));
294
+ fs2.closeSync(fd);
295
+ return {
296
+ path: lockPath,
297
+ metadata,
298
+ release: async () => {
299
+ if (fs2.existsSync(lockPath)) {
300
+ try {
301
+ const current = JSON.parse(fs2.readFileSync(lockPath, "utf-8"));
302
+ if (current.pid === process.pid) {
303
+ fs2.unlinkSync(lockPath);
304
+ }
305
+ } catch (e) {
306
+ }
307
+ }
308
+ }
309
+ };
310
+ } catch (e) {
311
+ if (e.code === "EEXIST") {
312
+ let existingMetadata = null;
313
+ try {
314
+ existingMetadata = JSON.parse(fs2.readFileSync(lockPath, "utf-8"));
315
+ } catch (err) {
316
+ throw new HardkasError("LOCK_METADATA_INVALID", `Lock file at ${lockPath} is corrupted.`, { cause: err });
317
+ }
318
+ if (existingMetadata) {
319
+ const isAlive = isProcessAlive(existingMetadata.pid);
320
+ if (!isAlive) {
321
+ throw new HardkasError(
322
+ "STALE_LOCK",
323
+ `Workspace is locked by a dead process (PID: ${existingMetadata.pid}).`,
324
+ { cause: existingMetadata }
325
+ );
326
+ }
327
+ if (args.wait && Date.now() - start < timeoutMs) {
328
+ await new Promise((resolve) => setTimeout(resolve, pollMs));
329
+ continue;
330
+ }
331
+ throw new HardkasError(
332
+ args.wait ? "LOCK_TIMEOUT" : "LOCK_HELD",
333
+ `Workspace is locked by another HardKAS process (PID: ${existingMetadata.pid}).`,
334
+ { cause: existingMetadata }
335
+ );
336
+ }
337
+ }
338
+ throw e;
339
+ }
340
+ }
341
+ }
342
+ async function withLock(args, fn) {
343
+ const handle = await acquireLock(args);
344
+ try {
345
+ return await fn(handle);
346
+ } finally {
347
+ await handle.release();
348
+ }
349
+ }
350
+ async function withLocks(rootDir, names, fn, options = {}) {
351
+ const sortedNames = [...names].sort((a, b) => {
352
+ const idxA = LOCK_ORDER.indexOf(a);
353
+ const idxB = LOCK_ORDER.indexOf(b);
354
+ return idxA - idxB;
355
+ });
356
+ const handles = [];
357
+ try {
358
+ for (const name of sortedNames) {
359
+ handles.push(await acquireLock({ rootDir, name, ...options }));
360
+ }
361
+ return await fn();
362
+ } finally {
363
+ for (const handle of handles.reverse()) {
364
+ await handle.release();
365
+ }
366
+ }
367
+ }
368
+ function isProcessAlive(pid) {
369
+ try {
370
+ process.kill(pid, 0);
371
+ return true;
372
+ } catch (e) {
373
+ return e.code !== "ESRCH";
374
+ }
375
+ }
376
+ function listLocks(rootDir) {
377
+ const lockDir = path2.join(rootDir, ".hardkas", "locks");
378
+ if (!fs2.existsSync(lockDir)) return [];
379
+ const files = fs2.readdirSync(lockDir).filter((f) => f.endsWith(".lock"));
380
+ const result = [];
381
+ for (const file of files) {
382
+ const lockPath = path2.join(lockDir, file);
383
+ try {
384
+ const metadata = JSON.parse(fs2.readFileSync(lockPath, "utf-8"));
385
+ result.push({
386
+ name: path2.basename(file, ".lock"),
387
+ metadata,
388
+ path: lockPath,
389
+ isAlive: metadata.hostname === os.hostname() ? isProcessAlive(metadata.pid) : true
390
+ // Assume alive if remote
391
+ });
392
+ } catch (e) {
393
+ }
394
+ }
395
+ return result;
396
+ }
397
+ function clearLock(rootDir, name, options = {}) {
398
+ const lockDir = path2.join(rootDir, ".hardkas", "locks");
399
+ const lockPath = path2.join(lockDir, `${name}.lock`);
400
+ if (!fs2.existsSync(lockPath)) return { cleared: false, reason: "Lock not found" };
401
+ let metadata;
402
+ try {
403
+ metadata = JSON.parse(fs2.readFileSync(lockPath, "utf-8"));
404
+ } catch (e) {
405
+ if (options.force) {
406
+ fs2.unlinkSync(lockPath);
407
+ return { cleared: true };
408
+ }
409
+ return { cleared: false, reason: "Corrupt metadata (use --force to clear)" };
410
+ }
411
+ const isLocal = metadata.hostname === os.hostname();
412
+ const isAlive = isLocal ? isProcessAlive(metadata.pid) : true;
413
+ if (options.ifDead) {
414
+ if (!isLocal) return { cleared: false, reason: "Cannot verify liveness of remote lock (host: " + metadata.hostname + ")" };
415
+ if (isAlive) return { cleared: false, reason: `Process (PID: ${metadata.pid}) is still alive` };
416
+ } else if (!options.force) {
417
+ return { cleared: false, reason: "Lock is potentially active. Use --force or --if-dead." };
418
+ }
419
+ fs2.unlinkSync(lockPath);
420
+ return { cleared: true };
421
+ }
422
+
114
423
  // src/index.ts
115
424
  var SOMPI_PER_KAS = 100000000n;
116
425
  var kaspaNetworkIdSchema = z.enum([
@@ -205,8 +514,10 @@ export {
205
514
  ExecutionModeSchema,
206
515
  HardkasError,
207
516
  InvariantViolationError,
517
+ LOCK_ORDER,
208
518
  NetworkIdSchema,
209
519
  SOMPI_PER_KAS,
520
+ acquireLock,
210
521
  artifactTypeSchema,
211
522
  asArtifactId,
212
523
  asContentHash,
@@ -220,15 +531,23 @@ export {
220
531
  asRpcEndpointId,
221
532
  asTxId,
222
533
  asWorkflowId,
534
+ clearLock,
223
535
  coreEvents,
224
536
  createEventEnvelope,
225
537
  executionModeSchema,
538
+ formatCorruptionIssue,
226
539
  formatSompi,
227
540
  hardkasConfigSchema,
541
+ isProcessAlive,
228
542
  kaspaNetworkIdSchema,
543
+ listLocks,
229
544
  maskSecrets,
230
545
  parseHardkasConfig,
231
546
  parseKasToSompi,
232
547
  redactSecret,
233
- validateEventEnvelope
548
+ validateEventEnvelope,
549
+ withLock,
550
+ withLocks,
551
+ writeFileAtomic,
552
+ writeFileAtomicSync
234
553
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hardkas/core",
3
- "version": "0.2.2-alpha.1",
3
+ "version": "0.3.0-alpha",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",