artifacty 0.2.0 → 0.4.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.
@@ -1,5 +1,5 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
- import { existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
2
+ import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, statSync, writeFileSync } from "node:fs";
3
3
  import { readFile } from "node:fs/promises";
4
4
  import { homedir } from "node:os";
5
5
  import path from "node:path";
@@ -112,6 +112,7 @@ export async function writeIndex(store, index) {
112
112
  const db = openDatabase(store);
113
113
  try {
114
114
  transaction(db, () => {
115
+ clearSearchIndex(db);
115
116
  db.prepare("DELETE FROM artifact_versions").run();
116
117
  db.prepare("DELETE FROM artifacts").run();
117
118
  for (const artifact of index.artifacts) {
@@ -120,6 +121,7 @@ export async function writeIndex(store, index) {
120
121
  insertVersionRecord(db, artifact.id, version);
121
122
  }
122
123
  }
124
+ rebuildSearchIndexInDb(db, store);
123
125
  });
124
126
  } finally {
125
127
  db.close();
@@ -155,6 +157,7 @@ export async function createArtifact(store = createStore(), input = {}) {
155
157
 
156
158
  insertArtifactRecord(db, artifact);
157
159
  insertVersionRecord(db, id, version);
160
+ upsertSearchIndex(db, artifact, version, normalized.content);
158
161
  insertAuditRecord(db, {
159
162
  action: input.auditAction || "create",
160
163
  artifactId: id,
@@ -209,6 +212,7 @@ export async function updateArtifact(store = createStore(), id, input = {}) {
209
212
  artifact.id
210
213
  );
211
214
  insertVersionRecord(db, artifact.id, version);
215
+ upsertSearchIndex(db, artifact, version, normalized.content);
212
216
  insertAuditRecord(db, {
213
217
  action: input.auditAction || "update",
214
218
  artifactId: artifact.id,
@@ -280,30 +284,316 @@ export async function restoreArtifact(store = createStore(), id, options = {}) {
280
284
  }
281
285
 
282
286
  export async function listArtifacts(store = createStore(), filters = {}) {
283
- const index = await loadIndex(store);
287
+ return (await listArtifactsPage(store, filters)).artifacts;
288
+ }
289
+
290
+ export async function listArtifactsPage(store = createStore(), filters = {}) {
291
+ const db = openDatabase(store);
284
292
  const limit = clampInteger(filters.limit, 1, 200, 50);
285
- const query = normalizeOptionalString(filters.query).toLowerCase();
293
+ const offset = clampInteger(filters.offset, 0, 1_000_000, 0);
294
+ const query = normalizeOptionalString(filters.query);
295
+ const normalizedQuery = query.toLowerCase();
286
296
  const tag = normalizeOptionalString(filters.tag).toLowerCase();
287
297
  const sourceAgent = normalizeOptionalString(filters.sourceAgent).toLowerCase();
288
298
 
289
- return index.artifacts
290
- .filter((artifact) => {
291
- if (!filters.includeArchived && artifact.archivedAt) {
292
- return false;
299
+ try {
300
+ if (query && searchIndexAvailable(db)) {
301
+ const ftsQuery = toFtsQuery(query);
302
+ if (ftsQuery) {
303
+ try {
304
+ const page = listArtifactsPageWithFts(db, {
305
+ ftsQuery,
306
+ tag,
307
+ sourceAgent,
308
+ includeArchived: filters.includeArchived,
309
+ limit,
310
+ offset
311
+ });
312
+ if (page.total > 0) {
313
+ return page;
314
+ }
315
+ } catch {
316
+ // Keep search usable even if the SQLite FTS parser rejects a query.
317
+ }
293
318
  }
294
- if (query && !artifactMatchesQuery(artifact, query)) {
295
- return false;
319
+ }
320
+
321
+ return listArtifactsPageWithSql(db, {
322
+ query: normalizedQuery,
323
+ tag,
324
+ sourceAgent,
325
+ includeArchived: filters.includeArchived,
326
+ limit,
327
+ offset
328
+ });
329
+ } finally {
330
+ db.close();
331
+ }
332
+ }
333
+
334
+ export async function rebuildSearchIndex(store = createStore()) {
335
+ const db = openDatabase(store);
336
+ try {
337
+ if (!searchIndexAvailable(db)) {
338
+ return {
339
+ ok: false,
340
+ fts5: false,
341
+ indexed: 0,
342
+ skipped: [],
343
+ message: "SQLite FTS5 is unavailable; metadata search fallback remains active."
344
+ };
345
+ }
346
+
347
+ return transaction(db, () => rebuildSearchIndexInDb(db, store));
348
+ } finally {
349
+ db.close();
350
+ }
351
+ }
352
+
353
+ export async function checkStoreIntegrity(store = createStore()) {
354
+ const db = openDatabase(store);
355
+ const checkedAt = new Date().toISOString();
356
+ try {
357
+ const artifacts = loadArtifacts(db);
358
+ const referencedPaths = new Set();
359
+ const missingFiles = [];
360
+ const hashMismatches = [];
361
+ const sizeMismatches = [];
362
+ const dbInconsistencies = [];
363
+ let totalBytes = 0;
364
+ let versionCount = 0;
365
+
366
+ for (const artifact of artifacts) {
367
+ if (!artifact.versions.length) {
368
+ dbInconsistencies.push({
369
+ artifactId: artifact.id,
370
+ issue: "artifact has no version rows"
371
+ });
296
372
  }
297
- if (tag && !artifact.tags.some((item) => item.toLowerCase() === tag)) {
298
- return false;
373
+ if (!artifact.versions.some((version) => version.version === artifact.latestVersion)) {
374
+ dbInconsistencies.push({
375
+ artifactId: artifact.id,
376
+ issue: `latest version ${artifact.latestVersion} has no version row`
377
+ });
299
378
  }
300
- if (sourceAgent && artifact.sourceAgent.toLowerCase() !== sourceAgent) {
301
- return false;
379
+
380
+ for (const version of artifact.versions) {
381
+ versionCount += 1;
382
+ const absolutePath = path.resolve(store.home, version.path);
383
+ referencedPaths.add(absolutePath);
384
+ if (!existsSync(absolutePath)) {
385
+ missingFiles.push({
386
+ artifactId: artifact.id,
387
+ version: version.version,
388
+ path: version.path
389
+ });
390
+ continue;
391
+ }
392
+
393
+ const content = readFileSync(absolutePath);
394
+ const actualSize = content.byteLength;
395
+ const actualSha256 = createHash("sha256").update(content).digest("hex");
396
+ totalBytes += actualSize;
397
+
398
+ if (actualSize !== version.sizeBytes) {
399
+ sizeMismatches.push({
400
+ artifactId: artifact.id,
401
+ version: version.version,
402
+ path: version.path,
403
+ expected: version.sizeBytes,
404
+ actual: actualSize
405
+ });
406
+ }
407
+ if (actualSha256 !== version.sha256) {
408
+ hashMismatches.push({
409
+ artifactId: artifact.id,
410
+ version: version.version,
411
+ path: version.path,
412
+ expected: version.sha256,
413
+ actual: actualSha256
414
+ });
415
+ }
302
416
  }
303
- return true;
304
- })
305
- .slice(0, limit)
306
- .map(toArtifactSummary);
417
+ }
418
+
419
+ const orphanFiles = listStoreFiles(store.artifactsDir)
420
+ .filter((filePath) => !referencedPaths.has(filePath))
421
+ .map((filePath) => {
422
+ const stat = statSync(filePath);
423
+ return {
424
+ path: path.relative(store.home, filePath),
425
+ sizeBytes: stat.size
426
+ };
427
+ });
428
+ const orphanBytes = orphanFiles.reduce((sum, file) => sum + file.sizeBytes, 0);
429
+ const ok =
430
+ missingFiles.length === 0 &&
431
+ hashMismatches.length === 0 &&
432
+ sizeMismatches.length === 0 &&
433
+ orphanFiles.length === 0 &&
434
+ dbInconsistencies.length === 0;
435
+
436
+ return {
437
+ ok,
438
+ checkedAt,
439
+ store: store.home,
440
+ artifactCount: artifacts.length,
441
+ versionCount,
442
+ totalBytes,
443
+ orphanBytes,
444
+ missingFiles,
445
+ hashMismatches,
446
+ sizeMismatches,
447
+ orphanFiles,
448
+ dbInconsistencies
449
+ };
450
+ } finally {
451
+ db.close();
452
+ }
453
+ }
454
+
455
+ function listArtifactsPageWithSql(db, filters) {
456
+ const { clauses, params } = artifactWhereClauses(filters);
457
+ if (filters.query) {
458
+ const like = `%${escapeLike(filters.query)}%`;
459
+ clauses.push(`(
460
+ LOWER(id) LIKE ? ESCAPE '\\' OR
461
+ LOWER(title) LIKE ? ESCAPE '\\' OR
462
+ LOWER(source_agent) LIKE ? ESCAPE '\\' OR
463
+ LOWER(tags_json) LIKE ? ESCAPE '\\'
464
+ )`);
465
+ params.push(like, like, like, like);
466
+ }
467
+
468
+ const whereSql = clauses.length ? `WHERE ${clauses.join(" AND ")}` : "";
469
+ const total = db.prepare(`SELECT COUNT(*) AS total FROM artifacts ${whereSql}`).get(...params).total;
470
+ const rows = db.prepare(`
471
+ SELECT id, title, artifact_type, schema_version, source_agent, tags_json, created_at, updated_at, latest_version, archived_at
472
+ FROM artifacts
473
+ ${whereSql}
474
+ ORDER BY updated_at DESC, created_at DESC
475
+ LIMIT ? OFFSET ?
476
+ `).all(...params, filters.limit, filters.offset);
477
+
478
+ return pagedResult({
479
+ artifacts: rows.map((row) => toArtifactSummary(artifactFromRow(db, row))),
480
+ total,
481
+ limit: filters.limit,
482
+ offset: filters.offset,
483
+ searchBackend: filters.query ? "metadata" : "sqlite"
484
+ });
485
+ }
486
+
487
+ function listArtifactsPageWithFts(db, filters) {
488
+ const { clauses, params } = artifactWhereClauses(filters, "a");
489
+ clauses.unshift("artifact_search MATCH ?");
490
+ params.unshift(filters.ftsQuery);
491
+ const whereSql = `WHERE ${clauses.join(" AND ")}`;
492
+ const total = db.prepare(`
493
+ SELECT COUNT(*) AS total
494
+ FROM artifact_search
495
+ JOIN artifacts a ON a.id = artifact_search.artifact_id
496
+ ${whereSql}
497
+ `).get(...params).total;
498
+ const rows = db.prepare(`
499
+ SELECT
500
+ a.id,
501
+ a.title,
502
+ a.artifact_type,
503
+ a.schema_version,
504
+ a.source_agent,
505
+ a.tags_json,
506
+ a.created_at,
507
+ a.updated_at,
508
+ a.latest_version,
509
+ a.archived_at,
510
+ bm25(artifact_search) AS search_rank,
511
+ snippet(artifact_search, 7, '', '', '...', 24) AS search_snippet
512
+ FROM artifact_search
513
+ JOIN artifacts a ON a.id = artifact_search.artifact_id
514
+ ${whereSql}
515
+ ORDER BY search_rank ASC, a.updated_at DESC, a.created_at DESC
516
+ LIMIT ? OFFSET ?
517
+ `).all(...params, filters.limit, filters.offset);
518
+
519
+ return pagedResult({
520
+ artifacts: rows.map((row) => ({
521
+ ...toArtifactSummary(artifactFromRow(db, row)),
522
+ searchScore: row.search_rank,
523
+ searchSnippet: normalizeWhitespace(row.search_snippet)
524
+ })),
525
+ total,
526
+ limit: filters.limit,
527
+ offset: filters.offset,
528
+ searchBackend: "fts5"
529
+ });
530
+ }
531
+
532
+ function artifactWhereClauses(filters, alias = "") {
533
+ const prefix = alias ? `${alias}.` : "";
534
+ const clauses = [];
535
+ const params = [];
536
+
537
+ if (!filters.includeArchived) {
538
+ clauses.push(`${prefix}archived_at IS NULL`);
539
+ }
540
+ if (filters.tag) {
541
+ clauses.push(`LOWER(${prefix}tags_json) LIKE ? ESCAPE '\\'`);
542
+ params.push(`%"${escapeLike(filters.tag)}"%`);
543
+ }
544
+ if (filters.sourceAgent) {
545
+ clauses.push(`LOWER(${prefix}source_agent) = ?`);
546
+ params.push(filters.sourceAgent);
547
+ }
548
+
549
+ return { clauses, params };
550
+ }
551
+
552
+ function pagedResult({ artifacts, total, limit, offset, searchBackend }) {
553
+ return {
554
+ artifacts,
555
+ total,
556
+ limit,
557
+ offset,
558
+ hasMore: offset + artifacts.length < total,
559
+ nextOffset: offset + artifacts.length < total ? offset + limit : null,
560
+ previousOffset: offset > 0 ? Math.max(0, offset - limit) : null,
561
+ search: {
562
+ backend: searchBackend
563
+ }
564
+ };
565
+ }
566
+
567
+ function artifactFromRow(db, row) {
568
+ return {
569
+ id: row.id,
570
+ title: row.title,
571
+ artifactType: row.artifact_type,
572
+ schemaVersion: row.schema_version,
573
+ sourceAgent: row.source_agent,
574
+ tags: parseJson(row.tags_json, []),
575
+ createdAt: row.created_at,
576
+ updatedAt: row.updated_at,
577
+ archivedAt: row.archived_at,
578
+ latestVersion: row.latest_version,
579
+ versions: loadVersions(db, row.id)
580
+ };
581
+ }
582
+
583
+ function escapeLike(value) {
584
+ return String(value).replace(/[\\%_]/g, (match) => `\\${match}`);
585
+ }
586
+
587
+ function normalizeWhitespace(value) {
588
+ return normalizeOptionalString(value).replace(/\s+/g, " ");
589
+ }
590
+
591
+ function toFtsQuery(value) {
592
+ const tokens = normalizeOptionalString(value).match(/[\p{L}\p{N}_-]+/gu) || [];
593
+ return tokens
594
+ .slice(0, 12)
595
+ .map((token) => `"${token.replaceAll("\"", "\"\"")}"`)
596
+ .join(" AND ");
307
597
  }
308
598
 
309
599
  export async function getArtifact(store = createStore(), id, options = {}) {
@@ -458,6 +748,7 @@ function openDatabase(store) {
458
748
  `);
459
749
  initializeSchema(db);
460
750
  migrateJsonIndex(db, store);
751
+ syncSearchIndexIfEmpty(db, store);
461
752
  return db;
462
753
  }
463
754
 
@@ -516,6 +807,7 @@ function initializeSchema(db) {
516
807
  ensureColumn(db, "artifacts", "schema_version", "INTEGER NOT NULL DEFAULT 1");
517
808
  ensureColumn(db, "artifacts", "archived_at", "TEXT");
518
809
  db.prepare("INSERT OR REPLACE INTO meta (key, value) VALUES ('store_version', ?)").run(String(STORE_VERSION));
810
+ ensureSearchTable(db);
519
811
  }
520
812
 
521
813
  function migrateJsonIndex(db, store) {
@@ -551,6 +843,135 @@ function migrateJsonIndex(db, store) {
551
843
  });
552
844
  }
553
845
 
846
+ function ensureSearchTable(db) {
847
+ try {
848
+ db.exec(`
849
+ CREATE VIRTUAL TABLE IF NOT EXISTS artifact_search USING fts5(
850
+ artifact_id UNINDEXED,
851
+ title,
852
+ source_agent,
853
+ artifact_type,
854
+ tags,
855
+ format,
856
+ metadata,
857
+ content
858
+ );
859
+ `);
860
+ db.prepare("INSERT OR REPLACE INTO meta (key, value) VALUES ('fts5_enabled', 'true')").run();
861
+ return true;
862
+ } catch (error) {
863
+ db.prepare("INSERT OR REPLACE INTO meta (key, value) VALUES ('fts5_enabled', ?)").run(`false:${error.message}`);
864
+ return false;
865
+ }
866
+ }
867
+
868
+ function searchIndexAvailable(db) {
869
+ const row = db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'artifact_search'").get();
870
+ return Boolean(row) || ensureSearchTable(db);
871
+ }
872
+
873
+ function syncSearchIndexIfEmpty(db, store) {
874
+ if (!searchIndexAvailable(db)) {
875
+ return;
876
+ }
877
+ const artifactCount = db.prepare("SELECT COUNT(*) AS count FROM artifacts").get().count;
878
+ if (artifactCount === 0) {
879
+ return;
880
+ }
881
+ const indexedCount = db.prepare("SELECT COUNT(*) AS count FROM artifact_search").get().count;
882
+ if (indexedCount === 0) {
883
+ transaction(db, () => rebuildSearchIndexInDb(db, store));
884
+ }
885
+ }
886
+
887
+ function clearSearchIndex(db) {
888
+ if (searchIndexAvailable(db)) {
889
+ db.prepare("DELETE FROM artifact_search").run();
890
+ }
891
+ }
892
+
893
+ function rebuildSearchIndexInDb(db, store) {
894
+ if (!searchIndexAvailable(db)) {
895
+ return {
896
+ ok: false,
897
+ fts5: false,
898
+ indexed: 0,
899
+ skipped: []
900
+ };
901
+ }
902
+
903
+ db.prepare("DELETE FROM artifact_search").run();
904
+ const skipped = [];
905
+ let indexed = 0;
906
+ for (const artifact of loadArtifacts(db)) {
907
+ const latest = artifact.versions.find((version) => version.version === artifact.latestVersion);
908
+ if (!latest) {
909
+ skipped.push({ artifactId: artifact.id, reason: "latest version row missing" });
910
+ continue;
911
+ }
912
+ const absolutePath = path.join(store.home, latest.path);
913
+ if (!existsSync(absolutePath)) {
914
+ skipped.push({ artifactId: artifact.id, version: latest.version, reason: "version file missing" });
915
+ continue;
916
+ }
917
+ upsertSearchIndex(db, artifact, latest, readFileSync(absolutePath, "utf8"));
918
+ indexed += 1;
919
+ }
920
+ db.prepare("INSERT OR REPLACE INTO meta (key, value) VALUES ('search_index_built_at', ?)").run(new Date().toISOString());
921
+ return {
922
+ ok: skipped.length === 0,
923
+ fts5: true,
924
+ indexed,
925
+ skipped
926
+ };
927
+ }
928
+
929
+ function upsertSearchIndex(db, artifact, version, content) {
930
+ if (!searchIndexAvailable(db)) {
931
+ return;
932
+ }
933
+ db.prepare("DELETE FROM artifact_search WHERE artifact_id = ?").run(artifact.id);
934
+ db.prepare(`
935
+ INSERT INTO artifact_search (
936
+ artifact_id, title, source_agent, artifact_type, tags, format, metadata, content
937
+ )
938
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
939
+ `).run(
940
+ artifact.id,
941
+ artifact.title,
942
+ artifact.sourceAgent,
943
+ artifact.artifactType,
944
+ artifact.tags.join(" "),
945
+ version.format,
946
+ metadataSearchText(version.metadata),
947
+ content
948
+ );
949
+ }
950
+
951
+ function metadataSearchText(metadata) {
952
+ if (!metadata || typeof metadata !== "object" || Array.isArray(metadata)) {
953
+ return "";
954
+ }
955
+ return JSON.stringify(metadata).slice(0, 64 * 1024);
956
+ }
957
+
958
+ function listStoreFiles(root) {
959
+ if (!existsSync(root)) {
960
+ return [];
961
+ }
962
+
963
+ const files = [];
964
+ for (const entry of readdirSync(root, { withFileTypes: true })) {
965
+ const fullPath = path.join(root, entry.name);
966
+ if (entry.isDirectory()) {
967
+ files.push(...listStoreFiles(fullPath));
968
+ } else if (entry.isFile()) {
969
+ files.push(path.resolve(fullPath));
970
+ }
971
+ }
972
+ return files;
973
+ }
974
+
554
975
  function loadArtifacts(db) {
555
976
  const rows = db.prepare(`
556
977
  SELECT id, title, artifact_type, schema_version, source_agent, tags_json, created_at, updated_at, latest_version, archived_at
@@ -914,19 +1335,6 @@ function makeArtifactId(title) {
914
1335
  return `${slug}-${randomUUID().slice(0, 8)}`;
915
1336
  }
916
1337
 
917
- function artifactMatchesQuery(artifact, query) {
918
- const haystack = [
919
- artifact.id,
920
- artifact.title,
921
- artifact.sourceAgent,
922
- ...artifact.tags
923
- ]
924
- .join(" ")
925
- .toLowerCase();
926
-
927
- return haystack.includes(query);
928
- }
929
-
930
1338
  function clampInteger(value, min, max, fallback) {
931
1339
  const parsed = Number.parseInt(value, 10);
932
1340
  if (Number.isNaN(parsed)) {