@hasna/skills 0.1.63 → 0.1.64
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 +28 -6
- package/bin/index.js +1593 -228
- package/bin/mcp.js +290 -24
- package/bin/migrate.js +229 -33
- package/bin/server.js +774 -116
- package/bin/worker.js +558 -79
- package/dist/cli/commands/registry-reconcile.d.ts +2 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +559 -251
- package/dist/lib/agent-sync.d.ts +26 -6
- package/dist/lib/auth-store.d.ts +37 -0
- package/dist/lib/config.d.ts +51 -0
- package/dist/lib/home-census.d.ts +4 -0
- package/dist/lib/home-migration.d.ts +8 -9
- package/dist/lib/native-storage.d.ts +1 -1
- package/dist/lib/portable-skills.d.ts +35 -6
- package/dist/lib/pull.d.ts +31 -0
- package/dist/lib/registry-reconcile.d.ts +114 -0
- package/dist/lib/registry.d.ts +7 -4
- package/dist/lib/remote-client.d.ts +118 -1
- package/dist/lib/remote-registry.d.ts +26 -0
- package/dist/lib/revision.d.ts +29 -0
- package/dist/sdk/index.js +1351 -353
- package/dist/server/app.d.ts +1 -1
- package/dist/server/config.d.ts +12 -2
- package/dist/server/rows.d.ts +2 -1
- package/dist/server/skills-api.d.ts +108 -6
- package/dist/server/sqlite-store.d.ts +20 -3
- package/dist/server/store.d.ts +31 -5
- package/dist/server/types.d.ts +98 -3
- package/dist/storage.js +7 -2
- package/migrations/postgres/0004_hosted_pins.sql +28 -0
- package/migrations/postgres/0005_revision_tombstone_registry.sql +37 -0
- package/migrations/postgres/0005_tag_projection.sql +36 -0
- package/migrations/sqlite/0004_hosted_pins.sql +21 -0
- package/migrations/sqlite/0005_revision_tombstone_registry.sql +18 -0
- package/migrations/sqlite/0005_tag_projection.sql +25 -0
- package/package.json +3 -2
package/bin/migrate.js
CHANGED
|
@@ -59,9 +59,10 @@ function resolveServerConfig(env = process.env) {
|
|
|
59
59
|
artifactBucket: env.HASNA_SKILLS_S3_BUCKET || env.SKILLS_S3_BUCKET || undefined,
|
|
60
60
|
artifactPrefix: normalizePrefix(env.HASNA_SKILLS_S3_PREFIX || env.SKILLS_S3_PREFIX || "skills/artifacts"),
|
|
61
61
|
inlineWorker: env.HASNA_SKILLS_INLINE_WORKER === "1",
|
|
62
|
-
bundleSigningKey: env.HASNA_SKILLS_SIGNING_KEY || undefined,
|
|
62
|
+
bundleSigningKey: env.HASNA_SKILLS_API_SIGNING_KEY || env.HASNA_SKILLS_SIGNING_KEY || undefined,
|
|
63
63
|
requestBodyLimitBytes: parsePositiveInt(env.HASNA_SKILLS_REQUEST_BODY_LIMIT_BYTES, 1e6),
|
|
64
64
|
skillBundleLimitBytes: parsePositiveInt(env.HASNA_SKILLS_BUNDLE_LIMIT_BYTES, 25000000),
|
|
65
|
+
tombstoneWindowMs: parsePositiveInt(env.HASNA_SKILLS_TOMBSTONE_WINDOW_MS, 7 * 24 * 60 * 60 * 1000),
|
|
65
66
|
publicBaseUrl: (env.SKILLS_PUBLIC_BASE_URL || localOrigin(host, port)).replace(/\/+$/, ""),
|
|
66
67
|
nodeEnv,
|
|
67
68
|
allowEphemeralStore: env.HASNA_SKILLS_ALLOW_EPHEMERAL_STORE === "1"
|
|
@@ -274,6 +275,7 @@ var ANY_SEGMENT_EXCLUDES = new Set([
|
|
|
274
275
|
".docker"
|
|
275
276
|
]);
|
|
276
277
|
var ROOT_EXCLUDES = new Set(["dist", "build", ".turbo"]);
|
|
278
|
+
var TOOL_SIDECAR_FILENAMES = new Set([".hasna-skills.json"]);
|
|
277
279
|
var CREDENTIAL_FILENAMES = new Set([
|
|
278
280
|
".npmrc",
|
|
279
281
|
".pypirc",
|
|
@@ -466,7 +468,20 @@ function rowToSkill(row) {
|
|
|
466
468
|
...row.bundle_byte_size === null || row.bundle_byte_size === undefined ? {} : { bundleByteSize: Number(row.bundle_byte_size) },
|
|
467
469
|
...typeof row.published_by_user_id === "string" ? { publishedByUserId: row.published_by_user_id } : {},
|
|
468
470
|
createdAt: dateString(row.created_at),
|
|
469
|
-
updatedAt: dateString(row.updated_at)
|
|
471
|
+
updatedAt: dateString(row.updated_at),
|
|
472
|
+
revisionId: String(row.revision_id ?? ""),
|
|
473
|
+
revisionNumber: Number(row.revision_number ?? 0),
|
|
474
|
+
...row.tombstoned_at ? { tombstonedAt: dateString(row.tombstoned_at) } : {},
|
|
475
|
+
...row.tombstone_purge_after ? { tombstonePurgeAfter: dateString(row.tombstone_purge_after) } : {}
|
|
476
|
+
};
|
|
477
|
+
}
|
|
478
|
+
function rowToPin(row) {
|
|
479
|
+
return {
|
|
480
|
+
orgId: String(row.org_id),
|
|
481
|
+
principal: String(row.principal),
|
|
482
|
+
slug: String(row.slug),
|
|
483
|
+
pinnedAt: dateString(row.pinned_at),
|
|
484
|
+
metadata: parseJsonObject(row.metadata_json)
|
|
470
485
|
};
|
|
471
486
|
}
|
|
472
487
|
function rowToSkillBundle(row) {
|
|
@@ -534,9 +549,45 @@ class StaleLeaseGenerationError extends Error {
|
|
|
534
549
|
}
|
|
535
550
|
}
|
|
536
551
|
|
|
552
|
+
class SkillRevisionConflictError extends Error {
|
|
553
|
+
slug;
|
|
554
|
+
expectedRevisionId;
|
|
555
|
+
currentRevisionId;
|
|
556
|
+
constructor(slug, expectedRevisionId, currentRevisionId) {
|
|
557
|
+
super(`revision conflict for '${slug}': expected revision ${expectedRevisionId ?? "(none)"}, ` + `current is ${currentRevisionId ?? "(none)"}. Refused rather than silently overwriting a newer revision.`);
|
|
558
|
+
this.name = "SkillRevisionConflictError";
|
|
559
|
+
this.slug = slug;
|
|
560
|
+
this.expectedRevisionId = expectedRevisionId;
|
|
561
|
+
this.currentRevisionId = currentRevisionId;
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
// src/lib/revision.ts
|
|
566
|
+
import { createHash as createHash2 } from "crypto";
|
|
567
|
+
function revisionIdOf(content) {
|
|
568
|
+
const canonical = JSON.stringify({
|
|
569
|
+
slug: content.slug,
|
|
570
|
+
displayName: content.displayName,
|
|
571
|
+
description: content.description,
|
|
572
|
+
category: content.category,
|
|
573
|
+
tags: content.tags,
|
|
574
|
+
source: content.source,
|
|
575
|
+
kind: content.kind,
|
|
576
|
+
version: content.version ?? null,
|
|
577
|
+
skillMd: content.skillMd ?? null,
|
|
578
|
+
bundleSha256: content.bundleSha256 ?? null,
|
|
579
|
+
bundleByteSize: content.bundleByteSize ?? null
|
|
580
|
+
});
|
|
581
|
+
return createHash2("sha256").update(canonical).digest("hex");
|
|
582
|
+
}
|
|
583
|
+
function revisionIdOfRecord(record) {
|
|
584
|
+
return revisionIdOf(record);
|
|
585
|
+
}
|
|
586
|
+
|
|
537
587
|
// src/server/sqlite-store.ts
|
|
538
588
|
var CLAIM_ATTEMPTS = 8;
|
|
539
589
|
var CLAIMABLE_STATUSES = ["queued", "retrying"];
|
|
590
|
+
var NO_REVISION_SENTINEL = "0000000000000000000000000000000000000000000000000000000000000000";
|
|
540
591
|
var LAST_USED_RESOLUTION_MS = 60000;
|
|
541
592
|
|
|
542
593
|
class SqliteSkillsStore {
|
|
@@ -556,12 +607,24 @@ class SqliteSkillsStore {
|
|
|
556
607
|
if (options.migrate !== false) {
|
|
557
608
|
applySqliteMigrations(this.db, options.migrationsDir);
|
|
558
609
|
}
|
|
610
|
+
this.backfillLegacyRevisions();
|
|
559
611
|
this.backend = {
|
|
560
612
|
kind: "sqlite",
|
|
561
613
|
durable: !inMemory,
|
|
562
614
|
label: inMemory ? "sqlite (in-memory)" : `sqlite (${path})`
|
|
563
615
|
};
|
|
564
616
|
}
|
|
617
|
+
backfillLegacyRevisions() {
|
|
618
|
+
const rows = this.all("SELECT * FROM skills_registry WHERE revision_id = ''", []);
|
|
619
|
+
for (const row of rows) {
|
|
620
|
+
const record = rowToSkill(row);
|
|
621
|
+
this.db.run("UPDATE skills_registry SET revision_id = ? WHERE org_id = ? AND slug = ?", [
|
|
622
|
+
revisionIdOfRecord(record),
|
|
623
|
+
record.orgId,
|
|
624
|
+
record.slug
|
|
625
|
+
]);
|
|
626
|
+
}
|
|
627
|
+
}
|
|
565
628
|
get database() {
|
|
566
629
|
return this.db;
|
|
567
630
|
}
|
|
@@ -785,8 +848,29 @@ class SqliteSkillsStore {
|
|
|
785
848
|
const orgId = input.principal.orgId;
|
|
786
849
|
const now = nowIso();
|
|
787
850
|
return this.db.transaction(() => {
|
|
788
|
-
const previous = this.get("SELECT bundle_sha256 FROM skills_registry WHERE org_id = ? AND slug = ?", [orgId, input.slug]);
|
|
851
|
+
const previous = this.get("SELECT revision_id, revision_number, bundle_sha256, bundle_byte_size, skill_md, tombstoned_at FROM skills_registry WHERE org_id = ? AND slug = ?", [orgId, input.slug]);
|
|
789
852
|
const previousSha = typeof previous?.bundle_sha256 === "string" ? previous.bundle_sha256 : null;
|
|
853
|
+
const previousRevisionId = typeof previous?.revision_id === "string" && previous.revision_id ? previous.revision_id : null;
|
|
854
|
+
const tombstoned = previous?.tombstoned_at != null;
|
|
855
|
+
const carriedSkillMd = typeof input.skillMd === "string" ? input.skillMd : typeof previous?.skill_md === "string" ? previous.skill_md : null;
|
|
856
|
+
if (previous && !tombstoned && input.expectedRevisionId !== previousRevisionId) {
|
|
857
|
+
throw new SkillRevisionConflictError(input.slug, input.expectedRevisionId, previousRevisionId);
|
|
858
|
+
}
|
|
859
|
+
const carriedSha = input.bundle?.sha256 ?? previousSha;
|
|
860
|
+
const carriedSize = input.bundle?.byteSize ?? (previous?.bundle_byte_size == null ? null : Number(previous.bundle_byte_size));
|
|
861
|
+
const revisionId = revisionIdOfRecord({
|
|
862
|
+
slug: input.slug,
|
|
863
|
+
displayName: input.displayName,
|
|
864
|
+
description: input.description,
|
|
865
|
+
category: input.category,
|
|
866
|
+
tags: input.tags,
|
|
867
|
+
source: input.source,
|
|
868
|
+
kind: input.kind,
|
|
869
|
+
...input.version ? { version: input.version } : {},
|
|
870
|
+
...carriedSkillMd ? { skillMd: carriedSkillMd } : {},
|
|
871
|
+
...carriedSha ? { bundleSha256: carriedSha } : {},
|
|
872
|
+
...carriedSize === null || carriedSize === undefined ? {} : { bundleByteSize: carriedSize }
|
|
873
|
+
});
|
|
790
874
|
if (input.bundle) {
|
|
791
875
|
this.db.run(`INSERT INTO skills_bundles (org_id, sha256, byte_size, content_type, storage_kind, storage_key, body_blob, created_at)
|
|
792
876
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
@@ -807,8 +891,8 @@ class SqliteSkillsStore {
|
|
|
807
891
|
]);
|
|
808
892
|
}
|
|
809
893
|
const row = this.get(`INSERT INTO skills_registry (org_id, slug, display_name, description, category, tags_json, source, kind, version, skill_md,
|
|
810
|
-
bundle_sha256, bundle_byte_size, published_by_user_id, created_at, updated_at)
|
|
811
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
894
|
+
bundle_sha256, bundle_byte_size, published_by_user_id, revision_id, revision_number, created_at, updated_at)
|
|
895
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?)
|
|
812
896
|
ON CONFLICT (org_id, slug) DO UPDATE SET
|
|
813
897
|
display_name = excluded.display_name,
|
|
814
898
|
description = excluded.description,
|
|
@@ -826,7 +910,16 @@ class SqliteSkillsStore {
|
|
|
826
910
|
bundle_sha256 = COALESCE(excluded.bundle_sha256, skills_registry.bundle_sha256),
|
|
827
911
|
bundle_byte_size = COALESCE(excluded.bundle_byte_size, skills_registry.bundle_byte_size),
|
|
828
912
|
published_by_user_id = excluded.published_by_user_id,
|
|
913
|
+
revision_id = excluded.revision_id,
|
|
914
|
+
-- Current + 1, not the inserted 1: on the update path the ACTUAL row's
|
|
915
|
+
-- counter is the truth (it may have advanced since the pre-read, which is
|
|
916
|
+
-- exactly what the WHERE guard below detects).
|
|
917
|
+
revision_number = skills_registry.revision_number + 1,
|
|
918
|
+
tombstoned_at = NULL,
|
|
919
|
+
tombstone_purge_after = NULL,
|
|
829
920
|
updated_at = excluded.updated_at
|
|
921
|
+
WHERE skills_registry.tombstoned_at IS NOT NULL
|
|
922
|
+
OR skills_registry.revision_id = ?
|
|
830
923
|
RETURNING *`, [
|
|
831
924
|
orgId,
|
|
832
925
|
input.slug,
|
|
@@ -837,62 +930,165 @@ class SqliteSkillsStore {
|
|
|
837
930
|
input.source,
|
|
838
931
|
input.kind,
|
|
839
932
|
input.version ?? null,
|
|
840
|
-
|
|
933
|
+
carriedSkillMd,
|
|
841
934
|
input.bundle?.sha256 ?? null,
|
|
842
935
|
input.bundle?.byteSize ?? null,
|
|
843
936
|
input.principal.userId,
|
|
937
|
+
revisionId,
|
|
938
|
+
now,
|
|
844
939
|
now,
|
|
845
|
-
|
|
940
|
+
input.expectedRevisionId ?? NO_REVISION_SENTINEL
|
|
846
941
|
]);
|
|
942
|
+
if (!row) {
|
|
943
|
+
const current = this.get("SELECT revision_id FROM skills_registry WHERE org_id = ? AND slug = ?", [orgId, input.slug]);
|
|
944
|
+
const currentId = typeof current?.revision_id === "string" ? current.revision_id : null;
|
|
945
|
+
throw new SkillRevisionConflictError(input.slug, input.expectedRevisionId, currentId);
|
|
946
|
+
}
|
|
847
947
|
if (previousSha && input.bundle && previousSha !== input.bundle.sha256)
|
|
848
948
|
this.collectOrphanBundle(orgId, previousSha);
|
|
949
|
+
this.db.run("DELETE FROM skills_tags WHERE org_id = ? AND slug = ?", [orgId, input.slug]);
|
|
950
|
+
const insertTag = this.db.prepare("INSERT OR IGNORE INTO skills_tags (org_id, slug, tag) VALUES (?, ?, ?)");
|
|
951
|
+
for (const tag of input.tags) {
|
|
952
|
+
if (!tag.trim())
|
|
953
|
+
continue;
|
|
954
|
+
insertTag.run(orgId, input.slug, tag);
|
|
955
|
+
}
|
|
849
956
|
return rowToSkill(row);
|
|
850
957
|
})();
|
|
851
958
|
}
|
|
852
959
|
async listSkills(principal) {
|
|
853
|
-
|
|
960
|
+
await this.purgeExpiredTombstones(principal);
|
|
961
|
+
return this.all("SELECT * FROM skills_registry WHERE org_id = ? AND tombstoned_at IS NULL ORDER BY slug ASC", [principal.orgId]).map(rowToSkill);
|
|
854
962
|
}
|
|
855
963
|
async getSkill(principal, slug) {
|
|
856
964
|
const row = this.get("SELECT * FROM skills_registry WHERE org_id = ? AND slug = ? LIMIT 1", [principal.orgId, slug]);
|
|
857
965
|
return row ? rowToSkill(row) : null;
|
|
858
966
|
}
|
|
859
|
-
async updateSkill(principal, slug, patch) {
|
|
967
|
+
async updateSkill(principal, slug, patch, expectedRevisionId) {
|
|
860
968
|
const current = await this.getSkill(principal, slug);
|
|
861
|
-
if (!current)
|
|
969
|
+
if (!current || current.tombstonedAt)
|
|
862
970
|
return null;
|
|
971
|
+
if (expectedRevisionId !== current.revisionId) {
|
|
972
|
+
throw new SkillRevisionConflictError(slug, expectedRevisionId, current.revisionId);
|
|
973
|
+
}
|
|
863
974
|
const next = { ...current, ...patch };
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
975
|
+
return this.db.transaction(() => {
|
|
976
|
+
const revisionId = revisionIdOfRecord(next);
|
|
977
|
+
const row = this.get(`UPDATE skills_registry
|
|
978
|
+
SET display_name = ?, description = ?, category = ?, tags_json = ?, kind = ?, version = ?, skill_md = ?,
|
|
979
|
+
revision_id = ?, revision_number = revision_number + 1, updated_at = ?
|
|
980
|
+
WHERE org_id = ? AND slug = ? AND tombstoned_at IS NULL AND revision_id = ?
|
|
981
|
+
RETURNING *`, [
|
|
982
|
+
next.displayName,
|
|
983
|
+
next.description,
|
|
984
|
+
next.category,
|
|
985
|
+
JSON.stringify(next.tags),
|
|
986
|
+
next.kind,
|
|
987
|
+
next.version ?? null,
|
|
988
|
+
next.skillMd ?? null,
|
|
989
|
+
revisionId,
|
|
990
|
+
nowIso(),
|
|
991
|
+
principal.orgId,
|
|
992
|
+
slug,
|
|
993
|
+
current.revisionId
|
|
994
|
+
]);
|
|
995
|
+
if (!row) {
|
|
996
|
+
const nowRow = this.get("SELECT revision_id, tombstoned_at FROM skills_registry WHERE org_id = ? AND slug = ? LIMIT 1", [
|
|
997
|
+
principal.orgId,
|
|
998
|
+
slug
|
|
999
|
+
]);
|
|
1000
|
+
if (nowRow && nowRow.tombstoned_at == null) {
|
|
1001
|
+
const currentId = typeof nowRow.revision_id === "string" ? nowRow.revision_id : null;
|
|
1002
|
+
throw new SkillRevisionConflictError(slug, expectedRevisionId, currentId);
|
|
1003
|
+
}
|
|
1004
|
+
return null;
|
|
1005
|
+
}
|
|
1006
|
+
this.db.run("DELETE FROM skills_tags WHERE org_id = ? AND slug = ?", [principal.orgId, slug]);
|
|
1007
|
+
const insertTag = this.db.prepare("INSERT OR IGNORE INTO skills_tags (org_id, slug, tag) VALUES (?, ?, ?)");
|
|
1008
|
+
for (const tag of next.tags) {
|
|
1009
|
+
if (!tag.trim())
|
|
1010
|
+
continue;
|
|
1011
|
+
insertTag.run(principal.orgId, slug, tag);
|
|
1012
|
+
}
|
|
1013
|
+
return rowToSkill(row);
|
|
1014
|
+
})();
|
|
880
1015
|
}
|
|
881
|
-
async deleteSkill(principal, slug) {
|
|
1016
|
+
async deleteSkill(principal, slug, tombstoneWindowMs) {
|
|
882
1017
|
return this.db.transaction(() => {
|
|
883
|
-
const existing = this.get("SELECT
|
|
1018
|
+
const existing = this.get("SELECT tombstoned_at FROM skills_registry WHERE org_id = ? AND slug = ?", [principal.orgId, slug]);
|
|
884
1019
|
if (!existing)
|
|
885
|
-
return
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
1020
|
+
return null;
|
|
1021
|
+
if (existing.tombstoned_at != null) {
|
|
1022
|
+
const row2 = this.get("SELECT * FROM skills_registry WHERE org_id = ? AND slug = ? LIMIT 1", [principal.orgId, slug]);
|
|
1023
|
+
return rowToSkill(row2);
|
|
1024
|
+
}
|
|
1025
|
+
const tombstonedAt = nowIso();
|
|
1026
|
+
const purgeAfter = new Date(Date.now() + tombstoneWindowMs).toISOString();
|
|
1027
|
+
const row = this.get(`UPDATE skills_registry
|
|
1028
|
+
SET tombstoned_at = ?, tombstone_purge_after = ?, updated_at = ?
|
|
1029
|
+
WHERE org_id = ? AND slug = ?
|
|
1030
|
+
RETURNING *`, [tombstonedAt, purgeAfter, tombstonedAt, principal.orgId, slug]);
|
|
1031
|
+
return rowToSkill(row);
|
|
1032
|
+
})();
|
|
1033
|
+
}
|
|
1034
|
+
async purgeExpiredTombstones(principal) {
|
|
1035
|
+
return this.db.transaction(() => {
|
|
1036
|
+
const now = nowIso();
|
|
1037
|
+
const expired = this.all("SELECT * FROM skills_registry WHERE org_id = ? AND tombstoned_at IS NOT NULL AND tombstone_purge_after <= ?", [principal.orgId, now]);
|
|
1038
|
+
const purged = [];
|
|
1039
|
+
for (const row of expired) {
|
|
1040
|
+
const record = rowToSkill(row);
|
|
1041
|
+
this.db.run("DELETE FROM skills_registry WHERE org_id = ? AND slug = ?", [principal.orgId, record.slug]);
|
|
1042
|
+
this.db.run("DELETE FROM skills_tags WHERE org_id = ? AND slug = ?", [principal.orgId, record.slug]);
|
|
1043
|
+
if (record.bundleSha256)
|
|
1044
|
+
this.collectOrphanBundle(principal.orgId, record.bundleSha256);
|
|
1045
|
+
purged.push(record);
|
|
1046
|
+
}
|
|
1047
|
+
return purged;
|
|
890
1048
|
})();
|
|
891
1049
|
}
|
|
892
1050
|
async getSkillBundle(principal, sha256) {
|
|
893
1051
|
const row = this.get("SELECT * FROM skills_bundles WHERE org_id = ? AND sha256 = ? LIMIT 1", [principal.orgId, sha256]);
|
|
894
1052
|
return row ? rowToSkillBundle(row) : null;
|
|
895
1053
|
}
|
|
1054
|
+
async pinSkill(principal, slug, metadata = {}) {
|
|
1055
|
+
const row = this.get(`INSERT INTO skills_pins (org_id, principal, slug, pinned_at, metadata_json)
|
|
1056
|
+
VALUES (?, ?, ?, ?, ?)
|
|
1057
|
+
ON CONFLICT (org_id, principal, slug) DO UPDATE SET
|
|
1058
|
+
pinned_at = excluded.pinned_at,
|
|
1059
|
+
metadata_json = excluded.metadata_json
|
|
1060
|
+
RETURNING *`, [principal.orgId, principal.apiKeyId, slug, nowIso(), JSON.stringify(metadata)]);
|
|
1061
|
+
return rowToPin(row);
|
|
1062
|
+
}
|
|
1063
|
+
async unpinSkill(principal, slug) {
|
|
1064
|
+
const result = this.db.run("DELETE FROM skills_pins WHERE org_id = ? AND principal = ? AND slug = ?", [principal.orgId, principal.apiKeyId, slug]);
|
|
1065
|
+
return result.changes > 0;
|
|
1066
|
+
}
|
|
1067
|
+
async listPins(principal) {
|
|
1068
|
+
return this.all("SELECT * FROM skills_pins WHERE org_id = ? AND principal = ? ORDER BY slug ASC", [principal.orgId, principal.apiKeyId]).map(rowToPin);
|
|
1069
|
+
}
|
|
1070
|
+
async listTags(principal) {
|
|
1071
|
+
await this.purgeExpiredTombstones(principal);
|
|
1072
|
+
return this.all("SELECT DISTINCT tag FROM skills_tags WHERE org_id = ? ORDER BY tag ASC", [principal.orgId]).map((row) => String(row.tag));
|
|
1073
|
+
}
|
|
1074
|
+
async listSkillsByTag(principal, tag) {
|
|
1075
|
+
await this.purgeExpiredTombstones(principal);
|
|
1076
|
+
return this.all(`SELECT s.* FROM skills_registry s
|
|
1077
|
+
JOIN skills_tags t ON t.org_id = s.org_id AND t.slug = s.slug
|
|
1078
|
+
WHERE t.org_id = ? AND t.tag = ? AND s.tombstoned_at IS NULL
|
|
1079
|
+
ORDER BY s.slug ASC`, [principal.orgId, tag]).map(rowToSkill);
|
|
1080
|
+
}
|
|
1081
|
+
async listPinsByTag(principal, tag) {
|
|
1082
|
+
await this.purgeExpiredTombstones(principal);
|
|
1083
|
+
return this.all(`SELECT p.* FROM skills_pins p
|
|
1084
|
+
JOIN skills_tags t ON t.org_id = p.org_id AND t.slug = p.slug
|
|
1085
|
+
JOIN skills_registry s ON s.org_id = p.org_id AND s.slug = p.slug
|
|
1086
|
+
WHERE p.org_id = ? AND p.principal = ? AND t.tag = ? AND s.tombstoned_at IS NULL
|
|
1087
|
+
ORDER BY p.slug ASC`, [principal.orgId, principal.apiKeyId, tag]).map(rowToPin);
|
|
1088
|
+
}
|
|
1089
|
+
async listPublishedSlugs(principal) {
|
|
1090
|
+
return this.all("SELECT slug FROM skills_registry WHERE org_id = ? AND tombstoned_at IS NULL ORDER BY slug ASC", [principal.orgId]).map((row) => String(row.slug));
|
|
1091
|
+
}
|
|
896
1092
|
collectOrphanBundle(orgId, sha256) {
|
|
897
1093
|
const referenced = this.get("SELECT 1 AS present FROM skills_registry WHERE org_id = ? AND bundle_sha256 = ? LIMIT 1", [orgId, sha256]);
|
|
898
1094
|
if (referenced)
|