@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/dist/server/app.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { type SkillsServerConfig } from "./config.js";
|
|
2
2
|
import { type MemorySkillsStore } from "./store.js";
|
|
3
|
-
import type
|
|
3
|
+
import { type SkillsProductStore } from "./types.js";
|
|
4
4
|
export interface SkillsServerOptions {
|
|
5
5
|
config?: Partial<SkillsServerConfig>;
|
|
6
6
|
store?: SkillsProductStore;
|
package/dist/server/config.d.ts
CHANGED
|
@@ -9,8 +9,12 @@ export interface SkillsServerConfig {
|
|
|
9
9
|
/**
|
|
10
10
|
* HMAC key for signing served skill bundles. When set, the bundle endpoint adds
|
|
11
11
|
* X-Skill-Bundle-Signature so clients holding the same key can verify that the bytes
|
|
12
|
-
* they pulled are the bytes this server decided to serve.
|
|
13
|
-
*
|
|
12
|
+
* they pulled are the bytes this server decided to serve. Never logged or printed.
|
|
13
|
+
*
|
|
14
|
+
* Canonical env name: HASNA_SKILLS_API_SIGNING_KEY — the name the production deploy
|
|
15
|
+
* mounts (infra-live hasna-app module, infra/apps/skills/prod/main.tf). The legacy
|
|
16
|
+
* HASNA_SKILLS_SIGNING_KEY is retained as a backwards-compatible alias; the canonical
|
|
17
|
+
* name wins when both are set. Decision record: todos ee1904ca, plan 8022d27f.
|
|
14
18
|
*/
|
|
15
19
|
bundleSigningKey?: string;
|
|
16
20
|
requestBodyLimitBytes: number;
|
|
@@ -25,6 +29,12 @@ export interface SkillsServerConfig {
|
|
|
25
29
|
* body is buffered, and the JSON cap is untouched.
|
|
26
30
|
*/
|
|
27
31
|
skillBundleLimitBytes: number;
|
|
32
|
+
/**
|
|
33
|
+
* How long a deleted skill answers 410 with a tombstone marker before it is purged
|
|
34
|
+
* (todos d061fcda). Within the window a client's pull can reconcile (remove its local
|
|
35
|
+
* copy); after it, the row and its bundle are gone and the slug is an ordinary 404.
|
|
36
|
+
*/
|
|
37
|
+
tombstoneWindowMs: number;
|
|
28
38
|
publicBaseUrl: string;
|
|
29
39
|
nodeEnv: string;
|
|
30
40
|
/**
|
package/dist/server/rows.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ServerArtifact, ServerRunLog, ServerRunRecord, ServerSkillBundle, ServerSkillRecord } from "./types.js";
|
|
1
|
+
import type { ServerArtifact, ServerPin, ServerRunLog, ServerRunRecord, ServerSkillBundle, ServerSkillRecord } from "./types.js";
|
|
2
2
|
export declare function nowIso(): string;
|
|
3
3
|
/**
|
|
4
4
|
* Coerce a caller-supplied row limit to a non-negative integer.
|
|
@@ -17,6 +17,7 @@ export declare function rowToRun(row: Record<string, unknown>): ServerRunRecord;
|
|
|
17
17
|
export declare function rowToLog(row: Record<string, unknown>): ServerRunLog;
|
|
18
18
|
export declare function rowToArtifact(row: Record<string, unknown>): ServerArtifact;
|
|
19
19
|
export declare function rowToSkill(row: Record<string, unknown>): ServerSkillRecord;
|
|
20
|
+
export declare function rowToPin(row: Record<string, unknown>): ServerPin;
|
|
20
21
|
export declare function rowToSkillBundle(row: Record<string, unknown>): ServerSkillBundle;
|
|
21
22
|
export declare function parseJsonObject(value: unknown): Record<string, unknown>;
|
|
22
23
|
export declare function parseJsonArray(value: unknown): string[];
|
|
@@ -2,7 +2,19 @@ import { type OwnedBytes } from "../lib/skill-bundle.js";
|
|
|
2
2
|
import type { SkillMeta } from "../lib/registry-types.js";
|
|
3
3
|
import type { ArtifactStorage } from "./artifact-storage.js";
|
|
4
4
|
import type { SkillsServerConfig } from "./config.js";
|
|
5
|
-
import type
|
|
5
|
+
import { type ApiPrincipal, type PublishSkillInput, type ServerPin, type ServerSkillRecord, type SkillsProductStore } from "./types.js";
|
|
6
|
+
/** Wire shape of a pin: the client-facing facts, without the storage columns. */
|
|
7
|
+
export declare function pinPayload(pin: ServerPin): Record<string, unknown>;
|
|
8
|
+
/**
|
|
9
|
+
* The metadata field of a pin body.
|
|
10
|
+
*
|
|
11
|
+
* Absent means an empty object (matching the schema default), present-and-not-an-
|
|
12
|
+
* object is refused rather than coerced: a client that sends `metadata: "team"`
|
|
13
|
+
* sent a bug, and storing it as `{}` would make the round-trip silently lose
|
|
14
|
+
* what the caller wrote. The route calls this on the parsed JSON body before
|
|
15
|
+
* touching the store, so a malformed body is a 400 and never a row.
|
|
16
|
+
*/
|
|
17
|
+
export declare function pinMetadataField(body: Record<string, unknown>): Record<string, unknown>;
|
|
6
18
|
export declare class SkillRequestError extends Error {
|
|
7
19
|
readonly status: number;
|
|
8
20
|
readonly code: string;
|
|
@@ -11,6 +23,34 @@ export declare class SkillRequestError extends Error {
|
|
|
11
23
|
/** SkillMeta shape for a published row, so a client can treat both kinds alike. */
|
|
12
24
|
export declare function publishedSkillMeta(record: ServerSkillRecord): SkillMeta;
|
|
13
25
|
export declare function publishedPayload(record: ServerSkillRecord): Record<string, unknown>;
|
|
26
|
+
/**
|
|
27
|
+
* The HTTP ETag for a published row: the revision id, quoted exactly as RFC 9110 wants.
|
|
28
|
+
* The quotes are load-bearing — a client that echoes the whole header value back as
|
|
29
|
+
* If-Match must send the quotes too.
|
|
30
|
+
*/
|
|
31
|
+
export declare function revisionEtag(revisionId: string): string;
|
|
32
|
+
/**
|
|
33
|
+
* Parse an If-Match header value into the revision id it names.
|
|
34
|
+
*
|
|
35
|
+
* Accepts the quoted form the server itself issues (RFC 9110) and a bare id for
|
|
36
|
+
* tolerance. `*` is refused: "any revision" would license exactly the silent overwrite
|
|
37
|
+
* the optimistic-concurrency guard exists to refuse. Malformed values are a 400
|
|
38
|
+
* statement about the request, never a fallback to "no guard".
|
|
39
|
+
*/
|
|
40
|
+
export declare function parseIfMatch(value: string | null): string | undefined;
|
|
41
|
+
/**
|
|
42
|
+
* Resolve a stored row that may be a tombstone (todos d061fcda).
|
|
43
|
+
*
|
|
44
|
+
* A tombstoned row answers 410 with the marker while its window is open, so a client's
|
|
45
|
+
* pull can reconcile (remove the local copy). Once the window has passed the tombstone
|
|
46
|
+
* is purged and the slug is simply gone (404). Returns "purged" when the caller should
|
|
47
|
+
* answer 404, a tombstone payload when it should answer 410, or "live" for a live row.
|
|
48
|
+
*
|
|
49
|
+
* Purging drops the store's bundle row and, for every purged record, the S3 object
|
|
50
|
+
* behind it — the same getSkillBundle-then-delete dance the publish/delete paths use,
|
|
51
|
+
* so an object whose digest another row still references survives.
|
|
52
|
+
*/
|
|
53
|
+
export declare function tombstoneStatus(store: SkillsProductStore, artifactStorage: ArtifactStorage, principal: ApiPrincipal, record: ServerSkillRecord): Promise<"live" | "purged" | Record<string, unknown>>;
|
|
14
54
|
/**
|
|
15
55
|
* Bundled corpus plus this org's published skills, published winning on a slug collision.
|
|
16
56
|
*
|
|
@@ -19,8 +59,66 @@ export declare function publishedPayload(record: ServerSkillRecord): Record<stri
|
|
|
19
59
|
* bundled `deploy-notes` there.
|
|
20
60
|
*/
|
|
21
61
|
export declare function listMergedSkills(store: SkillsProductStore, principal: ApiPrincipal): Promise<Record<string, unknown>[]>;
|
|
22
|
-
|
|
23
|
-
|
|
62
|
+
/**
|
|
63
|
+
* What a read of one slug resolves to: the org's published row (live or tombstoned) or
|
|
64
|
+
* nothing. The bundled-corpus fallback is the callers' decision, not this resolver's —
|
|
65
|
+
* a tombstoned slug must answer 410 even when a bundled skill of the same name exists,
|
|
66
|
+
* because the caller asked for the skill this instance serves under that slug.
|
|
67
|
+
*/
|
|
68
|
+
export type SkillReadResolution = {
|
|
69
|
+
kind: "published";
|
|
70
|
+
record: ServerSkillRecord;
|
|
71
|
+
} | {
|
|
72
|
+
kind: "tombstone";
|
|
73
|
+
payload: Record<string, unknown>;
|
|
74
|
+
} | {
|
|
75
|
+
kind: "absent";
|
|
76
|
+
};
|
|
77
|
+
export declare function resolvePublishedSkill(store: SkillsProductStore, artifactStorage: ArtifactStorage, principal: ApiPrincipal, slug: string): Promise<SkillReadResolution>;
|
|
78
|
+
/**
|
|
79
|
+
* Distinct tags across the org's registry view: the org's published tags (the
|
|
80
|
+
* indexed store query over the skills_tags projection, in both backends) plus
|
|
81
|
+
* the bundled corpus's, which is a fixed in-process set. The route serves the
|
|
82
|
+
* same universe GET /api/v1/skills serves, so a client can take any tag this
|
|
83
|
+
* list returns and filter with it. Sorted, de-duplicated, and emptied of blank
|
|
84
|
+
* entries to match the client contract (non-empty tag names).
|
|
85
|
+
*/
|
|
86
|
+
export declare function listOrgTags(store: SkillsProductStore, principal: ApiPrincipal): Promise<string[]>;
|
|
87
|
+
/**
|
|
88
|
+
* The merged registry view (bundled + published) filtered to skills carrying an
|
|
89
|
+
* exact tag. Same merge rules and payloads as listMergedSkills - the tag filter
|
|
90
|
+
* narrows what GET /api/v1/skills would have returned, it does not switch to a
|
|
91
|
+
* different universe. The published half comes from the indexed store query;
|
|
92
|
+
* only the static bundled corpus is filtered in-process, and a bundled skill
|
|
93
|
+
* whose slug a published row occupies is excluded so published-wins precedence
|
|
94
|
+
* holds for tag reads exactly as it does for the unfiltered view.
|
|
95
|
+
*/
|
|
96
|
+
export declare function listMergedSkillsByTag(store: SkillsProductStore, principal: ApiPrincipal, tag: string): Promise<Record<string, unknown>[]>;
|
|
97
|
+
/**
|
|
98
|
+
* The minimal per-skill wire row the tag/sync summary routes serve: the client's
|
|
99
|
+
* RemoteSkillSummary contract ({ slug, name?, version?, updatedAt? }).
|
|
100
|
+
*/
|
|
101
|
+
export declare function skillSummary(skill: Record<string, unknown>): Record<string, unknown>;
|
|
102
|
+
/**
|
|
103
|
+
* The principal's pins filtered to slugs whose skill (bundled or published, in
|
|
104
|
+
* this org's merged view) carries the exact tag. A pin carries no tag of its
|
|
105
|
+
* own - the filter resolves each pinned slug against the registry view, the
|
|
106
|
+
* same way the rest of the surface treats a pin as a fact about a skill.
|
|
107
|
+
*
|
|
108
|
+
* Published pins come from the indexed store query (skills_tags projection);
|
|
109
|
+
* the bundled-corpus half is resolved in-process against the static corpus and
|
|
110
|
+
* excludes slugs a published row occupies, so every pinned slug resolves
|
|
111
|
+
* through exactly one path (published wins) and no pin can appear twice.
|
|
112
|
+
*/
|
|
113
|
+
export declare function listPinsByTag(store: SkillsProductStore, principal: ApiPrincipal, tag: string): Promise<Record<string, unknown>[]>;
|
|
114
|
+
export declare function getMergedSkill(store: SkillsProductStore, artifactStorage: ArtifactStorage, principal: ApiPrincipal, slug: string): Promise<Record<string, unknown> | null>;
|
|
115
|
+
/**
|
|
116
|
+
* The SKILL.md document for one slug, or null when nothing is served under it.
|
|
117
|
+
*
|
|
118
|
+
* A tombstoned slug returns null here; the caller that needs the 410 marker resolves
|
|
119
|
+
* the tombstone itself via resolvePublishedSkill before calling.
|
|
120
|
+
*/
|
|
121
|
+
export declare function getMergedSkillMd(store: SkillsProductStore, artifactStorage: ArtifactStorage, principal: ApiPrincipal, slug: string): Promise<string | null>;
|
|
24
122
|
interface ParsedPublish {
|
|
25
123
|
input: Omit<PublishSkillInput, "principal">;
|
|
26
124
|
bundleBytes?: OwnedBytes;
|
|
@@ -45,11 +143,15 @@ interface ParsedPublish {
|
|
|
45
143
|
* measured.
|
|
46
144
|
*/
|
|
47
145
|
export declare function parsePublishRequest(request: Request, config: SkillsServerConfig): Promise<ParsedPublish>;
|
|
48
|
-
export declare function storePublishedSkill(store: SkillsProductStore, artifactStorage: ArtifactStorage, principal: ApiPrincipal, parsed: ParsedPublish): Promise<ServerSkillRecord>;
|
|
146
|
+
export declare function storePublishedSkill(store: SkillsProductStore, artifactStorage: ArtifactStorage, principal: ApiPrincipal, parsed: ParsedPublish, expectedRevisionId?: string): Promise<ServerSkillRecord>;
|
|
49
147
|
/**
|
|
50
|
-
*
|
|
148
|
+
* Tombstone a published skill (todos d061fcda): the row survives with a tombstone
|
|
149
|
+
* marker for `tombstoneWindowMs`, so reads answer 410 and a pulling client can
|
|
150
|
+
* reconcile, and the bundle is retained until the purge. The stored object is NOT
|
|
151
|
+
* discarded here — the tombstoned row still references it; the purge discards it.
|
|
152
|
+
* Returns the tombstoned record, or null when the org has no row by that slug.
|
|
51
153
|
*/
|
|
52
|
-
export declare function deletePublishedSkill(store: SkillsProductStore, artifactStorage: ArtifactStorage, principal: ApiPrincipal, slug: string): Promise<
|
|
154
|
+
export declare function deletePublishedSkill(store: SkillsProductStore, artifactStorage: ArtifactStorage, principal: ApiPrincipal, slug: string, tombstoneWindowMs: number): Promise<ServerSkillRecord | null>;
|
|
53
155
|
/**
|
|
54
156
|
* Read a published bundle back, refusing to serve bytes that no longer hash to the digest
|
|
55
157
|
* they were stored under.
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* requirement; changing Postgres behaviour is not this module's job.
|
|
14
14
|
*/
|
|
15
15
|
import { Database } from "bun:sqlite";
|
|
16
|
-
import type { ApiPrincipal, ClaimRunInput, CreateRunInput, PublishSkillInput, RunTransitionPatch, ServerArtifact, ServerRunLog, ServerRunRecord, ServerSkillBundle, ServerSkillRecord, SkillsProductStore, StoreBackendInfo, UpdateSkillPatch } from "./types.js";
|
|
16
|
+
import type { ApiPrincipal, ClaimRunInput, CreateRunInput, PublishSkillInput, RunTransitionPatch, ServerArtifact, ServerPin, ServerRunLog, ServerRunRecord, ServerSkillBundle, ServerSkillRecord, SkillsProductStore, StoreBackendInfo, UpdateSkillPatch } from "./types.js";
|
|
17
17
|
export interface SqliteStoreOptions {
|
|
18
18
|
/** Apply pending migrations on open. Default true - it is what makes zero-config work. */
|
|
19
19
|
migrate?: boolean;
|
|
@@ -34,6 +34,15 @@ export declare class SqliteSkillsStore implements SkillsProductStore {
|
|
|
34
34
|
private db;
|
|
35
35
|
private closed;
|
|
36
36
|
constructor(path?: string, options?: SqliteStoreOptions);
|
|
37
|
+
/**
|
|
38
|
+
* Give rows written before migration 0004 a real content revision id.
|
|
39
|
+
*
|
|
40
|
+
* The migration adds revision_id with DEFAULT '', which would make If-Match vacuous
|
|
41
|
+
* for legacy rows (every stale client matches the same empty string). This replaces
|
|
42
|
+
* the marker with a content sha, idempotently: new code always writes a full id, so
|
|
43
|
+
* the marker never reappears. Mirrors PostgresSkillsStore.backfillLegacyRevisions.
|
|
44
|
+
*/
|
|
45
|
+
private backfillLegacyRevisions;
|
|
37
46
|
/** Escape hatch for tests and for tooling that needs raw SQL against the same handle. */
|
|
38
47
|
get database(): Database;
|
|
39
48
|
close(): Promise<void>;
|
|
@@ -89,9 +98,17 @@ export declare class SqliteSkillsStore implements SkillsProductStore {
|
|
|
89
98
|
publishSkill(input: PublishSkillInput): Promise<ServerSkillRecord>;
|
|
90
99
|
listSkills(principal: ApiPrincipal): Promise<ServerSkillRecord[]>;
|
|
91
100
|
getSkill(principal: ApiPrincipal, slug: string): Promise<ServerSkillRecord | null>;
|
|
92
|
-
updateSkill(principal: ApiPrincipal, slug: string, patch: UpdateSkillPatch): Promise<ServerSkillRecord | null>;
|
|
93
|
-
deleteSkill(principal: ApiPrincipal, slug: string): Promise<
|
|
101
|
+
updateSkill(principal: ApiPrincipal, slug: string, patch: UpdateSkillPatch, expectedRevisionId?: string): Promise<ServerSkillRecord | null>;
|
|
102
|
+
deleteSkill(principal: ApiPrincipal, slug: string, tombstoneWindowMs: number): Promise<ServerSkillRecord | null>;
|
|
103
|
+
purgeExpiredTombstones(principal: ApiPrincipal): Promise<ServerSkillRecord[]>;
|
|
94
104
|
getSkillBundle(principal: ApiPrincipal, sha256: string): Promise<ServerSkillBundle | null>;
|
|
105
|
+
pinSkill(principal: ApiPrincipal, slug: string, metadata?: Record<string, unknown>): Promise<ServerPin>;
|
|
106
|
+
unpinSkill(principal: ApiPrincipal, slug: string): Promise<boolean>;
|
|
107
|
+
listPins(principal: ApiPrincipal): Promise<ServerPin[]>;
|
|
108
|
+
listTags(principal: ApiPrincipal): Promise<string[]>;
|
|
109
|
+
listSkillsByTag(principal: ApiPrincipal, tag: string): Promise<ServerSkillRecord[]>;
|
|
110
|
+
listPinsByTag(principal: ApiPrincipal, tag: string): Promise<ServerPin[]>;
|
|
111
|
+
listPublishedSlugs(principal: ApiPrincipal): Promise<string[]>;
|
|
95
112
|
/**
|
|
96
113
|
* Drop a bundle no remaining skill in the org points at.
|
|
97
114
|
*
|
package/dist/server/store.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ApiPrincipal, ClaimRunInput, CreateRunInput, PublishSkillInput, RunTransitionPatch, ServerArtifact, ServerRunLog, ServerRunRecord, ServerSkillBundle, ServerSkillRecord, SkillsProductStore, StoreBackendInfo, UpdateSkillPatch } from "./types.js";
|
|
1
|
+
import type { ApiPrincipal, ClaimRunInput, CreateRunInput, PublishSkillInput, RunTransitionPatch, ServerArtifact, ServerPin, ServerRunLog, ServerRunRecord, ServerSkillBundle, ServerSkillRecord, SkillsProductStore, StoreBackendInfo, UpdateSkillPatch } from "./types.js";
|
|
2
2
|
import { type SqliteStoreOptions } from "./sqlite-store.js";
|
|
3
3
|
export declare function createArtifactId(): string;
|
|
4
4
|
export interface StoreOptions {
|
|
@@ -31,6 +31,7 @@ export declare class MemorySkillsStore implements SkillsProductStore {
|
|
|
31
31
|
private idempotency;
|
|
32
32
|
private skills;
|
|
33
33
|
private bundles;
|
|
34
|
+
private pins;
|
|
34
35
|
constructor(apiKeys?: Array<{
|
|
35
36
|
token: string;
|
|
36
37
|
principal?: Partial<ApiPrincipal>;
|
|
@@ -68,9 +69,17 @@ export declare class MemorySkillsStore implements SkillsProductStore {
|
|
|
68
69
|
publishSkill(input: PublishSkillInput): Promise<ServerSkillRecord>;
|
|
69
70
|
listSkills(principal: ApiPrincipal): Promise<ServerSkillRecord[]>;
|
|
70
71
|
getSkill(principal: ApiPrincipal, slug: string): Promise<ServerSkillRecord | null>;
|
|
71
|
-
updateSkill(principal: ApiPrincipal, slug: string, patch: UpdateSkillPatch): Promise<ServerSkillRecord | null>;
|
|
72
|
-
deleteSkill(principal: ApiPrincipal, slug: string): Promise<
|
|
72
|
+
updateSkill(principal: ApiPrincipal, slug: string, patch: UpdateSkillPatch, expectedRevisionId?: string): Promise<ServerSkillRecord | null>;
|
|
73
|
+
deleteSkill(principal: ApiPrincipal, slug: string, tombstoneWindowMs: number): Promise<ServerSkillRecord | null>;
|
|
74
|
+
purgeExpiredTombstones(principal: ApiPrincipal): Promise<ServerSkillRecord[]>;
|
|
73
75
|
getSkillBundle(principal: ApiPrincipal, sha256: string): Promise<ServerSkillBundle | null>;
|
|
76
|
+
pinSkill(principal: ApiPrincipal, slug: string, metadata?: Record<string, unknown>): Promise<ServerPin>;
|
|
77
|
+
unpinSkill(principal: ApiPrincipal, slug: string): Promise<boolean>;
|
|
78
|
+
listPins(principal: ApiPrincipal): Promise<ServerPin[]>;
|
|
79
|
+
listTags(principal: ApiPrincipal): Promise<string[]>;
|
|
80
|
+
listSkillsByTag(principal: ApiPrincipal, tag: string): Promise<ServerSkillRecord[]>;
|
|
81
|
+
listPinsByTag(principal: ApiPrincipal, tag: string): Promise<ServerPin[]>;
|
|
82
|
+
listPublishedSlugs(principal: ApiPrincipal): Promise<string[]>;
|
|
74
83
|
private collectOrphanBundle;
|
|
75
84
|
private patchRun;
|
|
76
85
|
}
|
|
@@ -88,6 +97,15 @@ export declare class PostgresSkillsStore implements SkillsProductStore {
|
|
|
88
97
|
* own messages sometimes echo it, and this string ends up in logs.
|
|
89
98
|
*/
|
|
90
99
|
verifyConnectivity(): Promise<void>;
|
|
100
|
+
/**
|
|
101
|
+
* Give rows written before migration 0004 a real content revision id.
|
|
102
|
+
*
|
|
103
|
+
* The migration adds revision_id with DEFAULT '', which would make If-Match vacuous
|
|
104
|
+
* for legacy rows (every stale client matches the same empty string). This replaces
|
|
105
|
+
* the marker with a content sha, idempotently: new code always writes a full id, so
|
|
106
|
+
* the marker never reappears and the sweep costs one index scan on later opens.
|
|
107
|
+
*/
|
|
108
|
+
private backfillLegacyRevisions;
|
|
91
109
|
close(): Promise<void>;
|
|
92
110
|
ensureBootstrapApiKey(token: string, principal?: Partial<ApiPrincipal>): Promise<void>;
|
|
93
111
|
authenticateApiKeyHash(hash: string): Promise<ApiPrincipal | null>;
|
|
@@ -136,9 +154,17 @@ export declare class PostgresSkillsStore implements SkillsProductStore {
|
|
|
136
154
|
publishSkill(input: PublishSkillInput): Promise<ServerSkillRecord>;
|
|
137
155
|
listSkills(principal: ApiPrincipal): Promise<ServerSkillRecord[]>;
|
|
138
156
|
getSkill(principal: ApiPrincipal, slug: string): Promise<ServerSkillRecord | null>;
|
|
139
|
-
updateSkill(principal: ApiPrincipal, slug: string, patch: UpdateSkillPatch): Promise<ServerSkillRecord | null>;
|
|
140
|
-
deleteSkill(principal: ApiPrincipal, slug: string): Promise<
|
|
157
|
+
updateSkill(principal: ApiPrincipal, slug: string, patch: UpdateSkillPatch, expectedRevisionId?: string): Promise<ServerSkillRecord | null>;
|
|
158
|
+
deleteSkill(principal: ApiPrincipal, slug: string, tombstoneWindowMs: number): Promise<ServerSkillRecord | null>;
|
|
159
|
+
purgeExpiredTombstones(principal: ApiPrincipal): Promise<ServerSkillRecord[]>;
|
|
141
160
|
getSkillBundle(principal: ApiPrincipal, sha256: string): Promise<ServerSkillBundle | null>;
|
|
161
|
+
pinSkill(principal: ApiPrincipal, slug: string, metadata?: Record<string, unknown>): Promise<ServerPin>;
|
|
162
|
+
unpinSkill(principal: ApiPrincipal, slug: string): Promise<boolean>;
|
|
163
|
+
listPins(principal: ApiPrincipal): Promise<ServerPin[]>;
|
|
164
|
+
listTags(principal: ApiPrincipal): Promise<string[]>;
|
|
165
|
+
listSkillsByTag(principal: ApiPrincipal, tag: string): Promise<ServerSkillRecord[]>;
|
|
166
|
+
listPinsByTag(principal: ApiPrincipal, tag: string): Promise<ServerPin[]>;
|
|
167
|
+
listPublishedSlugs(principal: ApiPrincipal): Promise<string[]>;
|
|
142
168
|
/** Drop a bundle no remaining skill in the org points at. See the SQLite twin. */
|
|
143
169
|
private collectOrphanBundle;
|
|
144
170
|
}
|
package/dist/server/types.d.ts
CHANGED
|
@@ -15,6 +15,22 @@ export declare class StaleLeaseGenerationError extends Error {
|
|
|
15
15
|
readonly status: ServerRunStatus;
|
|
16
16
|
constructor(runId: string, expectedGeneration: number, currentGeneration: number, status: ServerRunStatus);
|
|
17
17
|
}
|
|
18
|
+
/**
|
|
19
|
+
* A publish or update was refused because it would silently overwrite a newer revision.
|
|
20
|
+
*
|
|
21
|
+
* The optimistic-concurrency guard (todos d061fcda): a write to an existing, live row
|
|
22
|
+
* must carry If-Match naming the current revision_id (the ETag the server issued). A
|
|
23
|
+
* missing guard, or one naming a different revision, is refused with this error instead
|
|
24
|
+
* of being applied. `currentRevisionId` is null when the row is absent or tombstoned —
|
|
25
|
+
* those are the two cases where the guard does not apply — and carries the live
|
|
26
|
+
* revision otherwise, so the caller can name what the writer was racing against.
|
|
27
|
+
*/
|
|
28
|
+
export declare class SkillRevisionConflictError extends Error {
|
|
29
|
+
readonly slug: string;
|
|
30
|
+
readonly expectedRevisionId: string | null | undefined;
|
|
31
|
+
readonly currentRevisionId: string | null;
|
|
32
|
+
constructor(slug: string, expectedRevisionId: string | null | undefined, currentRevisionId: string | null);
|
|
33
|
+
}
|
|
18
34
|
export interface ApiPrincipal {
|
|
19
35
|
apiKeyId: string;
|
|
20
36
|
orgId: string;
|
|
@@ -112,6 +128,26 @@ export interface ServerSkillRecord {
|
|
|
112
128
|
publishedByUserId?: string;
|
|
113
129
|
createdAt: string;
|
|
114
130
|
updatedAt: string;
|
|
131
|
+
/**
|
|
132
|
+
* Immutable content-addressed revision identity (sha-256 over the published content,
|
|
133
|
+
* computed in server/revision.ts). Same content -> same id; any content change mints
|
|
134
|
+
* a new one. This is the ETag every read issues and every guarded write must match.
|
|
135
|
+
*/
|
|
136
|
+
revisionId: string;
|
|
137
|
+
/**
|
|
138
|
+
* Monotonic per-slug write counter. Every publish and every metadata update bumps it,
|
|
139
|
+
* even when the content hash is unchanged, so "how many writes happened to this slug"
|
|
140
|
+
* is a number, not a digest comparison.
|
|
141
|
+
*/
|
|
142
|
+
revisionNumber: number;
|
|
143
|
+
/**
|
|
144
|
+
* Present when the slug was deleted within the tombstone window (todos d061fcda).
|
|
145
|
+
* Reads must answer 410 with the marker so a client's pull can reconcile; the row and
|
|
146
|
+
* its bundle are purged once tombstonePurgeAfter passes. A re-publish clears both
|
|
147
|
+
* fields and revives the slug as a fresh revision.
|
|
148
|
+
*/
|
|
149
|
+
tombstonedAt?: string;
|
|
150
|
+
tombstonePurgeAfter?: string;
|
|
115
151
|
}
|
|
116
152
|
/**
|
|
117
153
|
* Bundle bytes, addressed by their own digest.
|
|
@@ -121,6 +157,21 @@ export interface ServerSkillRecord {
|
|
|
121
157
|
* split ServerArtifact makes between `bodyText` and `storageKey`, except that a bundle is
|
|
122
158
|
* a gzipped tar and so cannot be a string at any layer.
|
|
123
159
|
*/
|
|
160
|
+
/**
|
|
161
|
+
* A skill the principal pinned on the hosted instance.
|
|
162
|
+
*
|
|
163
|
+
* The cloud-side twin of the local `.skills/project.json` pin: metadata-only,
|
|
164
|
+
* never content. `principal` is the api_keys.id of the API key that pinned
|
|
165
|
+
* (ApiPrincipal.apiKeyId) - a pin is a fact about a specific principal's
|
|
166
|
+
* selection, and two API keys in one org each have their own pin set.
|
|
167
|
+
*/
|
|
168
|
+
export interface ServerPin {
|
|
169
|
+
orgId: string;
|
|
170
|
+
principal: string;
|
|
171
|
+
slug: string;
|
|
172
|
+
pinnedAt: string;
|
|
173
|
+
metadata: Record<string, unknown>;
|
|
174
|
+
}
|
|
124
175
|
export interface ServerSkillBundle {
|
|
125
176
|
orgId: string;
|
|
126
177
|
sha256: string;
|
|
@@ -144,6 +195,15 @@ export interface PublishSkillInput {
|
|
|
144
195
|
skillMd?: string;
|
|
145
196
|
/** Omitted for a metadata-only publish or update. */
|
|
146
197
|
bundle?: Omit<ServerSkillBundle, "orgId" | "createdAt">;
|
|
198
|
+
/**
|
|
199
|
+
* Optimistic-concurrency guard (todos d061fcda): the revision_id (ETag) the writer
|
|
200
|
+
* read. A publish against an existing, LIVE row requires the guard to name that row's
|
|
201
|
+
* current revision_id; missing or mismatched is a SkillRevisionConflictError, never a
|
|
202
|
+
* silent overwrite. The guard is not required for a first publish (no row exists) or
|
|
203
|
+
* against a tombstoned row (nothing live to overwrite - the publish revives the slug
|
|
204
|
+
* as a fresh revision).
|
|
205
|
+
*/
|
|
206
|
+
expectedRevisionId?: string;
|
|
147
207
|
}
|
|
148
208
|
/** Metadata-only patch. Never touches the bundle; republish to change bytes. */
|
|
149
209
|
export type UpdateSkillPatch = Partial<Pick<ServerSkillRecord, "displayName" | "description" | "category" | "tags" | "version" | "kind" | "skillMd">>;
|
|
@@ -235,8 +295,43 @@ export interface SkillsProductStore {
|
|
|
235
295
|
publishSkill(input: PublishSkillInput): Promise<ServerSkillRecord>;
|
|
236
296
|
listSkills(principal: ApiPrincipal): Promise<ServerSkillRecord[]>;
|
|
237
297
|
getSkill(principal: ApiPrincipal, slug: string): Promise<ServerSkillRecord | null>;
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
298
|
+
/**
|
|
299
|
+
* Metadata-only patch, guarded by the same optimistic concurrency as publish: the
|
|
300
|
+
* row's current revision_id must match `expectedRevisionId` or
|
|
301
|
+
* SkillRevisionConflictError is thrown (409), never a silent overwrite. The revision
|
|
302
|
+
* advances on every successful update (new content sha, number + 1). Returns null
|
|
303
|
+
* when the org has no skill by that slug, or when the row is tombstoned.
|
|
304
|
+
*/
|
|
305
|
+
updateSkill(principal: ApiPrincipal, slug: string, patch: UpdateSkillPatch, expectedRevisionId?: string): Promise<ServerSkillRecord | null>;
|
|
306
|
+
/**
|
|
307
|
+
* Tombstone a skill instead of hard-deleting it (todos d061fcda): the row is stamped
|
|
308
|
+
* tombstoned_at + tombstone_purge_after (now + tombstoneWindowMs) and kept, so reads
|
|
309
|
+
* can answer 410 with the marker and a pulling client can reconcile, and the bundle
|
|
310
|
+
* survives until the purge. Returns the tombstoned record, or null when the org has
|
|
311
|
+
* no row by that slug. A second delete of an already-tombstoned slug returns the
|
|
312
|
+
* existing tombstone (idempotent; the window is not extended).
|
|
313
|
+
*/
|
|
314
|
+
deleteSkill(principal: ApiPrincipal, slug: string, tombstoneWindowMs: number): Promise<ServerSkillRecord | null>;
|
|
315
|
+
/**
|
|
316
|
+
* Drop every tombstoned row whose window has expired, collecting the bundles nothing
|
|
317
|
+
* (live or tombstoned) references. Called on the read paths and by listSkills so the
|
|
318
|
+
* purge is lazy but does not wait for a slug to be read. Returns the purged records so
|
|
319
|
+
* the caller can discard their stored objects (S3).
|
|
320
|
+
*/
|
|
321
|
+
purgeExpiredTombstones(principal: ApiPrincipal): Promise<ServerSkillRecord[]>;
|
|
241
322
|
getSkillBundle(principal: ApiPrincipal, sha256: string): Promise<ServerSkillBundle | null>;
|
|
323
|
+
pinSkill(principal: ApiPrincipal, slug: string, metadata?: Record<string, unknown>): Promise<ServerPin>;
|
|
324
|
+
/** False when this principal has no pin by that slug. */
|
|
325
|
+
unpinSkill(principal: ApiPrincipal, slug: string): Promise<boolean>;
|
|
326
|
+
listPins(principal: ApiPrincipal): Promise<ServerPin[]>;
|
|
327
|
+
listTags(principal: ApiPrincipal): Promise<string[]>;
|
|
328
|
+
listSkillsByTag(principal: ApiPrincipal, tag: string): Promise<ServerSkillRecord[]>;
|
|
329
|
+
listPinsByTag(principal: ApiPrincipal, tag: string): Promise<ServerPin[]>;
|
|
330
|
+
/**
|
|
331
|
+
* The slugs of the org's live (non-tombstoned) published skills. The tag
|
|
332
|
+
* routes use this to resolve merged-view precedence: a bundled skill whose
|
|
333
|
+
* slug a published row occupies must not resurface under a tag filter or in
|
|
334
|
+
* the tag list. A single-column scan of the (org_id, slug) primary key.
|
|
335
|
+
*/
|
|
336
|
+
listPublishedSlugs(principal: ApiPrincipal): Promise<string[]>;
|
|
242
337
|
}
|
package/dist/storage.js
CHANGED
|
@@ -162,6 +162,11 @@ function normalizeConfigValue(key, value) {
|
|
|
162
162
|
}
|
|
163
163
|
var DATA_DIR_ENV = "HASNA_SKILLS_DIR";
|
|
164
164
|
var INSTALLED_SKILLS_DIRNAME = "installed";
|
|
165
|
+
var SKILLS_CACHE_DIRNAME = "skills";
|
|
166
|
+
var LAYOUT_MIGRATION_RECORD = ".layout-migration.json";
|
|
167
|
+
function isOwnerLayoutMigrated(appDir) {
|
|
168
|
+
return existsSync(join(appDir, SKILLS_CACHE_DIRNAME, LAYOUT_MIGRATION_RECORD));
|
|
169
|
+
}
|
|
165
170
|
function getDataDir() {
|
|
166
171
|
const override = process.env[DATA_DIR_ENV];
|
|
167
172
|
if (override) {
|
|
@@ -498,7 +503,7 @@ function getSkillsNativeStorageStatus(options = {}) {
|
|
|
498
503
|
const s3BucketEnv = readStorageEnv(env, "s3Bucket");
|
|
499
504
|
const targetDir = options.targetDir ?? process.cwd();
|
|
500
505
|
return {
|
|
501
|
-
package: "
|
|
506
|
+
package: "skills",
|
|
502
507
|
tables: [...SKILLS_STORAGE_TABLES],
|
|
503
508
|
env: {
|
|
504
509
|
databaseUrl: SKILLS_NATIVE_STORAGE_ENV.databaseUrl,
|
|
@@ -670,7 +675,7 @@ function createSkillsSnapshotSyncRecord(snapshot, options = {}) {
|
|
|
670
675
|
kind: "local-snapshot",
|
|
671
676
|
id: options.id ?? "project-state",
|
|
672
677
|
updatedAt: snapshot.exportedAt,
|
|
673
|
-
source: options.source ?? "
|
|
678
|
+
source: options.source ?? "skills",
|
|
674
679
|
payload: snapshot
|
|
675
680
|
};
|
|
676
681
|
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
-- Hosted pins: the cloud becomes a source of truth for what the user selected.
|
|
2
|
+
--
|
|
3
|
+
-- Today a pin lives only in local project state (.skills/project.json); an
|
|
4
|
+
-- instance has no pins surface, so nothing a user selected is ever visible to
|
|
5
|
+
-- the server, to a second machine, or to another operator of the same org.
|
|
6
|
+
-- This table is that surface.
|
|
7
|
+
--
|
|
8
|
+
-- Row identity is (org_id, principal, slug): one org, one API key, one slug -
|
|
9
|
+
-- a pin is a fact about a specific principal's selection. `principal` stores
|
|
10
|
+
-- the api_keys.id of the API key that pinned (ApiPrincipal.apiKeyId), so two
|
|
11
|
+
-- API keys in the same org each have their own pin set, and two organizations
|
|
12
|
+
-- can pin the same slug without colliding. The task's UNIQUE(org,principal,slug)
|
|
13
|
+
-- is expressed as the composite primary key, which implies the same uniqueness
|
|
14
|
+
-- and is the row's natural identity - there is no separate id to mint.
|
|
15
|
+
--
|
|
16
|
+
-- Pins are metadata-only: slug plus a free-form metadata object. Nothing here
|
|
17
|
+
-- references skills_registry, so a pin may name a skill the org has not
|
|
18
|
+
-- published (a bundled skill, or one that will exist later) without a
|
|
19
|
+
-- foreign-key constraint choosing which of those futures is allowed.
|
|
20
|
+
|
|
21
|
+
CREATE TABLE IF NOT EXISTS skills_pins (
|
|
22
|
+
org_id text NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
|
|
23
|
+
principal text NOT NULL,
|
|
24
|
+
slug text NOT NULL,
|
|
25
|
+
pinned_at timestamptz NOT NULL DEFAULT now(),
|
|
26
|
+
metadata_json jsonb NOT NULL DEFAULT '{}'::jsonb,
|
|
27
|
+
PRIMARY KEY (org_id, principal, slug)
|
|
28
|
+
);
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
-- Revision identity, optimistic concurrency, and the tombstone contract for the hosted
|
|
2
|
+
-- registry (todos d061fcda, plan 8022d27f).
|
|
3
|
+
--
|
|
4
|
+
-- Three behaviours this migration makes possible, all in one table because they are
|
|
5
|
+
-- three properties of the same row:
|
|
6
|
+
--
|
|
7
|
+
-- 1. IMMUTABLE REVISION IDENTITY. revision_id is a sha-256 over the row's published
|
|
8
|
+
-- content (computed in TypeScript, src/lib/revision.ts) and revision_number is a
|
|
9
|
+
-- monotonic per-slug write counter. Together they let a client prove WHICH revision
|
|
10
|
+
-- it installed and let the server refuse a stale overwrite.
|
|
11
|
+
--
|
|
12
|
+
-- 2. OPTIMISTIC CONCURRENCY. publish/update require an If-Match carrying the current
|
|
13
|
+
-- revision_id; a mismatch (or a missing guard against an existing row) returns 409
|
|
14
|
+
-- instead of silently overwriting a newer revision. The guard is enforced in the
|
|
15
|
+
-- store's SQL (WHERE ... revision_id = <expected> on the upsert), never in
|
|
16
|
+
-- read-then-write JS, so two concurrent writers cannot both pass a stale check.
|
|
17
|
+
--
|
|
18
|
+
-- 3. TOMBSTONES. delete no longer drops the row: it stamps tombstoned_at +
|
|
19
|
+
-- tombstone_purge_after (delete time + the configured window). Reads within the
|
|
20
|
+
-- window answer 410 with the tombstone marker so a client's pull can reconcile
|
|
21
|
+
-- (remove the local copy); after the window a read or list purges the row and its
|
|
22
|
+
-- bundle. Re-publish over a tombstoned slug revives it as a fresh revision.
|
|
23
|
+
--
|
|
24
|
+
-- The columns are added by ALTER rather than by the 0002 drop-and-recreate pattern:
|
|
25
|
+
-- the registry can hold tenant rows now (publishing shipped in the 0002 change), so a
|
|
26
|
+
-- rebuild would be data loss. Rows that predate this migration get revision_id '' and
|
|
27
|
+
-- revision_number 0; the store backfills a content sha for them on first open, because
|
|
28
|
+
-- an empty revision id would make If-Match vacuous for legacy rows (every stale client
|
|
29
|
+
-- would "match" the same empty string and two concurrent writers could both land).
|
|
30
|
+
--
|
|
31
|
+
-- Hand-written parallel of migrations/sqlite/0005_revision_tombstone_registry.sql; the
|
|
32
|
+
-- dialect differences are the same documented set as 0001-0003 (timestamptz -> text).
|
|
33
|
+
|
|
34
|
+
ALTER TABLE skills_registry ADD COLUMN revision_id text NOT NULL DEFAULT '';
|
|
35
|
+
ALTER TABLE skills_registry ADD COLUMN revision_number integer NOT NULL DEFAULT 0;
|
|
36
|
+
ALTER TABLE skills_registry ADD COLUMN tombstoned_at timestamptz;
|
|
37
|
+
ALTER TABLE skills_registry ADD COLUMN tombstone_purge_after timestamptz;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
-- The hosted tag surface (T7) queries tag membership from this relational
|
|
2
|
+
-- projection of skills_registry.tags_json, in both dialects with one identical
|
|
3
|
+
-- shape. SQLite cannot index json_each() over the JSON column, and Postgres
|
|
4
|
+
-- rows written by the current path hold the array text inside a jsonb scalar
|
|
5
|
+
-- (bun's driver binds a JS string against a ::jsonb cast as a JSON string), so
|
|
6
|
+
-- neither dialect can serve an indexed array-expansion query directly. The
|
|
7
|
+
-- projection turns tag membership into a plain indexed lookup: the PRIMARY KEY
|
|
8
|
+
-- serves (org_id, slug) maintenance and the org_tag index serves (org_id, tag)
|
|
9
|
+
-- filters. The write paths keep it in step; the backfill below covers rows
|
|
10
|
+
-- that predate it.
|
|
11
|
+
-- No composite FK to skills_registry: the schema-parity parser cannot
|
|
12
|
+
-- represent a multi-column FK identically in both dialects, and every write
|
|
13
|
+
-- path here maintains the projection explicitly (publish/update replace its
|
|
14
|
+
-- rows, the tombstone purge removes them with the row), so the constraint
|
|
15
|
+
-- would be redundancy.
|
|
16
|
+
CREATE TABLE skills_tags (
|
|
17
|
+
org_id text NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
|
|
18
|
+
slug text NOT NULL,
|
|
19
|
+
tag text NOT NULL,
|
|
20
|
+
PRIMARY KEY (org_id, slug, tag)
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
CREATE INDEX skills_tags_org_tag_idx ON skills_tags (org_id, tag);
|
|
24
|
+
|
|
25
|
+
-- Backfill every existing row's tags. tags_json may be a real array or,
|
|
26
|
+
-- because of the jsonb-scalar write path, the array text inside a jsonb
|
|
27
|
+
-- string; both shapes are expanded here.
|
|
28
|
+
INSERT INTO skills_tags (org_id, slug, tag)
|
|
29
|
+
SELECT DISTINCT r.org_id, r.slug, t.tag
|
|
30
|
+
FROM skills_registry r
|
|
31
|
+
CROSS JOIN LATERAL jsonb_array_elements_text(
|
|
32
|
+
CASE WHEN jsonb_typeof(r.tags_json) = 'array' THEN r.tags_json
|
|
33
|
+
WHEN jsonb_typeof(r.tags_json) = 'string' THEN (r.tags_json #>> '{}')::jsonb
|
|
34
|
+
ELSE '[]'::jsonb END
|
|
35
|
+
) AS t(tag)
|
|
36
|
+
WHERE t.tag <> '';
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
-- Hosted pins: the cloud becomes a source of truth for what the user selected.
|
|
2
|
+
--
|
|
3
|
+
-- SQLite dialect of 0004_hosted_pins. Read the Postgres file for the why; the
|
|
4
|
+
-- only differences here are the documented dialect mapping:
|
|
5
|
+
-- * timestamptz -> text holding a UTC ISO-8601 instant (pinned_at).
|
|
6
|
+
-- * jsonb -> text holding JSON (metadata_json).
|
|
7
|
+
--
|
|
8
|
+
-- No RLS: the tenant fence on this table is the org-scoped predicate in the
|
|
9
|
+
-- store, exactly as it is for skills_registry and skills_bundles. RLS was
|
|
10
|
+
-- armed in 0003 only for the run-output tables, where a worker path writes
|
|
11
|
+
-- outside a tenant context; pins are written and read only by the API under
|
|
12
|
+
-- the requesting principal's org, so there is no context-less writer to fence.
|
|
13
|
+
|
|
14
|
+
CREATE TABLE IF NOT EXISTS skills_pins (
|
|
15
|
+
org_id text NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
|
|
16
|
+
principal text NOT NULL,
|
|
17
|
+
slug text NOT NULL,
|
|
18
|
+
pinned_at text NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
|
19
|
+
metadata_json text NOT NULL DEFAULT '{}',
|
|
20
|
+
PRIMARY KEY (org_id, principal, slug)
|
|
21
|
+
);
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
-- SQLite dialect of 0005_revision_tombstone_registry.
|
|
2
|
+
--
|
|
3
|
+
-- Hand-written parallel of migrations/postgres/0005_revision_tombstone_registry.sql. Read
|
|
4
|
+
-- that file for why the registry gains revision_id/revision_number (immutable revision
|
|
5
|
+
-- identity), the optimistic-concurrency guard, and the tombstone contract; the rationale
|
|
6
|
+
-- is not repeated here, only the dialect differences are.
|
|
7
|
+
--
|
|
8
|
+
-- Deliberate, shape-preserving dialect differences (same set as 0001):
|
|
9
|
+
-- * timestamptz -> text holding a UTC ISO-8601 instant.
|
|
10
|
+
--
|
|
11
|
+
-- SQLite requires a non-null literal DEFAULT for a NOT NULL ADD COLUMN; the '' default is
|
|
12
|
+
-- the legacy-row marker the store's backfill (src/lib/revision.ts) replaces with a
|
|
13
|
+
-- content sha on first open, exactly as on Postgres.
|
|
14
|
+
|
|
15
|
+
ALTER TABLE skills_registry ADD COLUMN revision_id text NOT NULL DEFAULT '';
|
|
16
|
+
ALTER TABLE skills_registry ADD COLUMN revision_number integer NOT NULL DEFAULT 0;
|
|
17
|
+
ALTER TABLE skills_registry ADD COLUMN tombstoned_at text;
|
|
18
|
+
ALTER TABLE skills_registry ADD COLUMN tombstone_purge_after text;
|