@ouraihub/aether-worker 0.0.0-stage → 0.1.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.
@@ -0,0 +1,203 @@
1
+ import type { ManualSave, SkillRef } from '@ouraihub/aether-contracts'
2
+ import { badRequest, KitFailure } from '@ouraihub/aether-core'
3
+ import { readSkillSource } from '@ouraihub/aether-core/skills/package'
4
+ import * as skills from '@ouraihub/aether-core/skills/service'
5
+ import { exact, object, parseManualSave, revision, strings, text } from '@ouraihub/aether-core/skills/validation'
6
+ import type { Actor, WorkerContext } from '../context.js'
7
+ import type { DocumentStore } from '../storage.js'
8
+ import type { FileStore } from '../files.js'
9
+ import { loadSkills, parseRefs, serializeSkill, type LoadedSkills, type SkillBlobRef } from './persistence.js'
10
+
11
+ /** All catalog mutations commit in one actor-scoped CAS; conflicts never overwrite newer content. */
12
+ export class SkillsService {
13
+ private readonly store: DocumentStore
14
+ private readonly blobs: FileStore
15
+ private readonly builtins: readonly skills.BuiltinSkill[]
16
+ constructor(store: DocumentStore, blobs: FileStore, builtins: readonly skills.BuiltinSkill[] = []) {
17
+ this.store = store
18
+ this.blobs = blobs
19
+ this.builtins = builtins
20
+ }
21
+
22
+ private async commit(
23
+ actor: Actor,
24
+ previous: LoadedSkills,
25
+ next: skills.SkillCatalog,
26
+ expected: number | null,
27
+ ): Promise<{ refs: readonly SkillBlobRef[]; revision: number }> {
28
+ const created: string[] = []
29
+ const refs: SkillBlobRef[] = []
30
+ try {
31
+ for (const skill of next) {
32
+ const oldRef = previous.refs.find((ref) => ref.id === skill.id)
33
+ if (oldRef && previous.catalog.find((entry) => entry.id === skill.id) === skill) {
34
+ refs.push(oldRef)
35
+ continue
36
+ }
37
+ const blobId = crypto.randomUUID()
38
+ await this.blobs.put(actor, blobId, { mime: 'application/json', data: serializeSkill(skill) })
39
+ created.push(blobId)
40
+ refs.push({ ...skills.metadata(skill), blobId, digest: skill.digest })
41
+ }
42
+ const saved = await this.store.compareAndSet(actor, 'skills', 'catalog', expected, refs)
43
+ return { refs, revision: saved.revision }
44
+ } catch (error) {
45
+ // Only a definite rejection proves these blobs are not live catalog references.
46
+ if (error instanceof KitFailure && error.code === 'conflict') {
47
+ await Promise.all(created.map((id) => this.blobs.remove(actor, id)))
48
+ }
49
+ throw error
50
+ }
51
+ }
52
+ private async load(actor: Actor, now: number): Promise<LoadedSkills & { revision: number | null }> {
53
+ const document = await this.store.get(actor, 'skills', 'catalog')
54
+ const current = document === null ? { catalog: [], refs: [] } : await loadSkills(this.blobs, actor, document.value)
55
+ const synced = await skills.syncBuiltins(current.catalog, this.builtins, now)
56
+ if (synced === current.catalog) return { ...current, revision: document?.revision ?? null }
57
+ const saved = await this.commit(actor, current, synced, document?.revision ?? null)
58
+ return { catalog: synced, ...saved }
59
+ }
60
+ private async mutate(
61
+ actor: Actor,
62
+ now: number,
63
+ operation: (catalog: skills.SkillCatalog) => Promise<skills.SkillCatalog> | skills.SkillCatalog,
64
+ ) {
65
+ const current = await this.load(actor, now)
66
+ const next = await operation(current.catalog)
67
+ await this.commit(actor, current, next, current.revision)
68
+ return skills.list(next)
69
+ }
70
+ async list(actor: Actor, now: number) {
71
+ if (this.builtins.length !== 0) return skills.list((await this.load(actor, now)).catalog)
72
+ const document = await this.store.get(actor, 'skills', 'catalog')
73
+ const refs = document === null ? [] : parseRefs(document.value)
74
+ if (refs.some((ref) => ref.origin === 'builtin')) return skills.list((await this.load(actor, now)).catalog)
75
+ return refs.map((ref) => ({
76
+ id: ref.id,
77
+ name: ref.name,
78
+ description: ref.description,
79
+ version: ref.version,
80
+ origin: ref.origin,
81
+ revision: ref.revision,
82
+ bodyChars: ref.bodyChars,
83
+ updatedAt: ref.updatedAt,
84
+ hasScripts: ref.hasScripts,
85
+ fileCount: ref.fileCount,
86
+ }))
87
+ }
88
+ async get(actor: Actor, id: string, now: number) {
89
+ return skills.get((await this.load(actor, now)).catalog, id)
90
+ }
91
+ async files(actor: Actor, id: string, expected: number, now: number) {
92
+ return skills.files((await this.load(actor, now)).catalog, id, expected)
93
+ }
94
+ async file(actor: Actor, id: string, expected: number, path: string, now: number) {
95
+ return skills.file((await this.load(actor, now)).catalog, id, expected, path)
96
+ }
97
+ async snapshot(actor: Actor, refs: readonly SkillRef[], now: number) {
98
+ return skills.snapshot((await this.load(actor, now)).catalog, refs)
99
+ }
100
+ async preview(actor: Actor, source: unknown, now: number) {
101
+ const content = await readSkillSource(source)
102
+ return skills.preview((await this.load(actor, now)).catalog, content)
103
+ }
104
+ async install(actor: Actor, source: unknown, confirmedDigest: string, replace: boolean, newId: string, now: number) {
105
+ const content = await readSkillSource(source)
106
+ return this.mutate(actor, now, (catalog) => skills.install(catalog, content, confirmedDigest, replace, newId, now))
107
+ }
108
+ async save(actor: Actor, input: ManualSave, newId: string, now: number) {
109
+ const checked = parseManualSave(input)
110
+ return this.mutate(actor, now, (catalog) => skills.save(catalog, checked, newId, now))
111
+ }
112
+ async duplicate(actor: Actor, id: string, newName: string, newId: string, now: number) {
113
+ return this.mutate(actor, now, (catalog) => skills.duplicate(catalog, id, newName, newId, now))
114
+ }
115
+ async reorder(actor: Actor, ids: readonly string[], now: number) {
116
+ return this.mutate(actor, now, (catalog) => skills.reorder(catalog, ids))
117
+ }
118
+ async remove(actor: Actor, id: string, now: number) {
119
+ return this.mutate(actor, now, (catalog) => skills.remove(catalog, id))
120
+ }
121
+
122
+ async handle(request: Request, context: WorkerContext): Promise<Response | null> {
123
+ const url = new URL(request.url)
124
+ const base = '/api/v1/skills'
125
+ if (url.pathname !== base && !url.pathname.startsWith(`${base}/`)) return null
126
+ const { actor, now } = context
127
+ if (context.signal.aborted) throw new KitFailure('cancelled', 'Cancelled')
128
+ const path = url.pathname.slice(base.length)
129
+ if (request.method === 'GET') {
130
+ if (path === '') return Response.json(await this.list(actor, now))
131
+ const parts = path.slice(1).split('/')
132
+ const id = decodeURIComponent(parts[0] ?? '')
133
+ if (parts.length === 1) return Response.json(await this.get(actor, id, now))
134
+ const expected = revision(Number(url.searchParams.get('revision')))
135
+ if (parts.length === 2 && parts[1] === 'files') return Response.json(await this.files(actor, id, expected, now))
136
+ if (parts.length === 2 && parts[1] === 'file') {
137
+ const bytes = await this.file(actor, id, expected, text(url.searchParams.get('path'), 'path'), now)
138
+ return new Response(bytes, {
139
+ headers: { 'Content-Type': 'application/octet-stream', 'X-Content-Type-Options': 'nosniff' },
140
+ })
141
+ }
142
+ }
143
+ if (request.method === 'DELETE' && /^\/[^/]+$/u.test(path))
144
+ return Response.json(await this.remove(actor, decodeURIComponent(path.slice(1)), now))
145
+ if (request.method === 'POST') {
146
+ let raw: unknown
147
+ try {
148
+ raw = await request.json()
149
+ } catch {
150
+ throw badRequest('body', 'invalid_json')
151
+ }
152
+ if (path === '/save') return Response.json(await this.save(actor, parseManualSave(raw), crypto.randomUUID(), now))
153
+ const body = object(raw, 'body')
154
+ if (path === '/preview') {
155
+ exact(body, ['source'], 'body')
156
+ return Response.json(await this.preview(actor, body.source, now))
157
+ }
158
+ if (path === '/install') {
159
+ exact(body, ['source', 'confirmedDigest', 'replace'], 'body')
160
+ if (typeof body.replace !== 'boolean') throw badRequest('replace', 'invalid')
161
+ return Response.json(
162
+ await this.install(
163
+ actor,
164
+ body.source,
165
+ text(body.confirmedDigest, 'confirmedDigest'),
166
+ body.replace,
167
+ crypto.randomUUID(),
168
+ now,
169
+ ),
170
+ )
171
+ }
172
+ if (path === '/reorder') {
173
+ exact(body, ['ids'], 'body')
174
+ return Response.json(await this.reorder(actor, strings(body.ids, 'ids'), now))
175
+ }
176
+ if (path === '/snapshot') {
177
+ exact(body, ['refs'], 'body')
178
+ const refs: unknown = body.refs
179
+ if (!Array.isArray(refs)) throw badRequest('refs', 'invalid')
180
+ const checked = refs.map((value: unknown) => {
181
+ const ref = object(value, 'ref')
182
+ exact(ref, ['id', 'revision'], 'ref')
183
+ return { id: text(ref.id, 'id'), revision: revision(ref.revision) }
184
+ })
185
+ return Response.json(await this.snapshot(actor, checked, now))
186
+ }
187
+ const duplicate = /^\/([^/]+)\/duplicate$/u.exec(path)
188
+ if (duplicate?.[1] !== undefined) {
189
+ exact(body, ['newName'], 'body')
190
+ return Response.json(
191
+ await this.duplicate(
192
+ actor,
193
+ decodeURIComponent(duplicate[1]),
194
+ text(body.newName, 'newName'),
195
+ crypto.randomUUID(),
196
+ now,
197
+ ),
198
+ )
199
+ }
200
+ }
201
+ throw badRequest('route', 'unsupported')
202
+ }
203
+ }
package/src/storage.ts ADDED
@@ -0,0 +1,198 @@
1
+ import { badRequest, KitFailure } from '@ouraihub/aether-core'
2
+ import { ownerKey, type Actor } from './context.ts'
3
+ import type { D1Database, R2Bucket } from './cloudflare.ts'
4
+
5
+ export interface StoredDocument {
6
+ readonly key: string
7
+ readonly revision: number
8
+ readonly value: unknown
9
+ }
10
+ export interface DocumentListOptions {
11
+ readonly after?: string
12
+ readonly limit?: number
13
+ }
14
+ export interface DocumentStore {
15
+ get(owner: Actor, collection: string, key: string): Promise<StoredDocument | null>
16
+ list(owner: Actor, collection: string, options?: DocumentListOptions): Promise<readonly StoredDocument[]>
17
+ compareAndSet(
18
+ owner: Actor,
19
+ collection: string,
20
+ key: string,
21
+ expectedRevision: number | null,
22
+ value: unknown,
23
+ ): Promise<StoredDocument>
24
+ remove(owner: Actor, collection: string, key: string, expectedRevision: number): Promise<void>
25
+ }
26
+ const INLINE_BYTES = 256 * 1024
27
+ const MAX_LIST = 200
28
+ function identifier(value: string): void {
29
+ if (!value || value.length > 256) throw badRequest('document', 'invalid_identifier')
30
+ }
31
+ function revision(value: number): void {
32
+ if (!Number.isSafeInteger(value) || value < 1 || value >= Number.MAX_SAFE_INTEGER)
33
+ throw badRequest('revision', 'invalid')
34
+ }
35
+ function scope(owner: Actor, collection: string): readonly [string, string, string, string, string] {
36
+ identifier(collection)
37
+ return [owner.applicationId, JSON.stringify(owner.tenantId), owner.subjectId, ownerKey(owner), collection]
38
+ }
39
+ export async function validateD1Schema(database: D1Database): Promise<void> {
40
+ let row: unknown
41
+ try {
42
+ row = await database.prepare('SELECT version FROM aether_schema WHERE singleton = 1').first()
43
+ } catch {
44
+ throw new KitFailure('incompatible_db', 'Apply the supported Aether public migration before startup')
45
+ }
46
+ if (typeof row !== 'object' || row === null || !('version' in row) || row.version !== 1) {
47
+ throw new KitFailure('incompatible_db', 'Apply the supported Aether public migration before startup')
48
+ }
49
+ }
50
+ /** D1 rows stay below its 2,000,000 byte limit. Large functional documents live
51
+ * in immutable scoped R2 blobs; credentials inside documents MUST already be sealed.
52
+ * Tombstones reserve deleted keys. Superseded/uncertain-commit blobs stay for explicit
53
+ * deployment GC: deleting them inline would race readers or a committed SQL response.
54
+ */
55
+ export class D1DocumentStore implements DocumentStore {
56
+ readonly #database: D1Database
57
+ readonly #blobs: R2Bucket | undefined
58
+ readonly #maxBytes: number
59
+ constructor(database: D1Database, options: { readonly blobs?: R2Bucket; readonly maxDocumentBytes?: number } = {}) {
60
+ this.#database = database
61
+ this.#blobs = options.blobs
62
+ this.#maxBytes = options.maxDocumentBytes ?? 64 * 1024 * 1024
63
+ if (!Number.isSafeInteger(this.#maxBytes) || this.#maxBytes < INLINE_BYTES)
64
+ throw badRequest('maxDocumentBytes', 'invalid')
65
+ }
66
+ async #blobPrefix(owner: Actor): Promise<string> {
67
+ const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(ownerKey(owner)))
68
+ return `aether/documents/v1/${Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, '0')).join('')}/`
69
+ }
70
+ async #parse(owner: Actor, row: unknown): Promise<StoredDocument> {
71
+ if (
72
+ typeof row !== 'object' ||
73
+ row === null ||
74
+ !('key' in row) ||
75
+ typeof row.key !== 'string' ||
76
+ !('revision' in row) ||
77
+ typeof row.revision !== 'number' ||
78
+ !Number.isSafeInteger(row.revision) ||
79
+ row.revision < 1 ||
80
+ !('value_json' in row) ||
81
+ !('blob_key' in row)
82
+ )
83
+ throw new KitFailure('internal', 'Stored document is unreadable')
84
+ let json: string
85
+ if (typeof row.value_json === 'string' && row.blob_key === null) json = row.value_json
86
+ else if (row.value_json === null && typeof row.blob_key === 'string' && this.#blobs) {
87
+ const prefix = await this.#blobPrefix(owner)
88
+ if (!row.blob_key.startsWith(prefix) || !/^[a-f0-9-]{36}$/u.test(row.blob_key.slice(prefix.length)))
89
+ throw new KitFailure('internal', 'Stored document is unreadable')
90
+ const blob = await this.#blobs.get(row.blob_key)
91
+ if (!blob || blob.size > this.#maxBytes) throw new KitFailure('internal', 'Stored document is unreadable')
92
+ const bytes = await blob.arrayBuffer()
93
+ if (bytes.byteLength !== blob.size || bytes.byteLength > this.#maxBytes)
94
+ throw new KitFailure('internal', 'Stored document is unreadable')
95
+ json = new TextDecoder('utf-8', { fatal: true }).decode(bytes)
96
+ } else throw new KitFailure('internal', 'Stored document is unreadable')
97
+ if (new TextEncoder().encode(json).byteLength > this.#maxBytes)
98
+ throw new KitFailure('internal', 'Stored document is unreadable')
99
+ try {
100
+ const value: unknown = JSON.parse(json)
101
+ return { key: row.key, revision: row.revision, value }
102
+ } catch {
103
+ throw new KitFailure('internal', 'Stored document is unreadable')
104
+ }
105
+ }
106
+ async get(owner: Actor, collection: string, key: string): Promise<StoredDocument | null> {
107
+ identifier(key)
108
+ const row = await this.#database
109
+ .prepare(
110
+ 'SELECT key, revision, value_json, blob_key FROM aether_documents WHERE application_id = ? AND tenant_key = ? AND subject_id = ? AND owner_key = ? AND collection = ? AND key = ? AND deleted = 0',
111
+ )
112
+ .bind(...scope(owner, collection), key)
113
+ .first()
114
+ return row === null ? null : this.#parse(owner, row)
115
+ }
116
+ async list(owner: Actor, collection: string, options: DocumentListOptions = {}): Promise<readonly StoredDocument[]> {
117
+ const limit = options.limit ?? MAX_LIST
118
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > MAX_LIST) throw badRequest('limit', 'invalid', MAX_LIST)
119
+ if (options.after !== undefined) identifier(options.after)
120
+ const result = await this.#database
121
+ .prepare(
122
+ 'SELECT key, revision, value_json, blob_key FROM aether_documents WHERE application_id = ? AND tenant_key = ? AND subject_id = ? AND owner_key = ? AND collection = ? AND deleted = 0 AND key > ? ORDER BY key LIMIT ?',
123
+ )
124
+ .bind(...scope(owner, collection), options.after ?? '', limit)
125
+ .all()
126
+ if (!result.success || !Array.isArray(result.results)) throw new KitFailure('internal', 'Document listing failed')
127
+ const documents: StoredDocument[] = []
128
+ // Sequential hydration bounds R2 concurrency/memory to one document at a time.
129
+ for (const row of result.results) documents.push(await this.#parse(owner, row))
130
+ return documents
131
+ }
132
+ async compareAndSet(
133
+ owner: Actor,
134
+ collection: string,
135
+ key: string,
136
+ expectedRevision: number | null,
137
+ value: unknown,
138
+ ): Promise<StoredDocument> {
139
+ identifier(key)
140
+ if (expectedRevision !== null) revision(expectedRevision)
141
+ const values = scope(owner, collection)
142
+ let json: string | undefined
143
+ try {
144
+ json = JSON.stringify(value)
145
+ } catch {
146
+ throw badRequest('document', 'invalid_json')
147
+ }
148
+ if (json === undefined) throw badRequest('document', 'invalid_json')
149
+ const bytes = new TextEncoder().encode(json)
150
+ if (bytes.byteLength > this.#maxBytes) throw badRequest('document', 'too_large', this.#maxBytes)
151
+ let inline: string | null = json
152
+ let blobKey: string | null = null
153
+ if (bytes.byteLength > INLINE_BYTES) {
154
+ if (!this.#blobs) throw new KitFailure('needs_config', 'Large documents require an R2 blob binding')
155
+ blobKey = `${await this.#blobPrefix(owner)}${crypto.randomUUID()}`
156
+ const written = await this.#blobs.put(blobKey, bytes, {
157
+ httpMetadata: { contentType: 'application/json' },
158
+ onlyIf: { etagDoesNotMatch: '*' },
159
+ })
160
+ if (written === null) throw new KitFailure('conflict', 'Document blob already exists')
161
+ inline = null
162
+ }
163
+ const row =
164
+ expectedRevision === null
165
+ ? await this.#database
166
+ .prepare(
167
+ 'INSERT INTO aether_documents (application_id, tenant_key, subject_id, owner_key, collection, key, revision, value_json, blob_key, deleted, updated_at) VALUES (?, ?, ?, ?, ?, ?, 1, ?, ?, 0, ?) ON CONFLICT DO NOTHING RETURNING key, revision, value_json, blob_key',
168
+ )
169
+ .bind(...values, key, inline, blobKey, Date.now())
170
+ .first()
171
+ : await this.#database
172
+ .prepare(
173
+ 'UPDATE aether_documents SET revision = revision + 1, value_json = ?, blob_key = ?, updated_at = ? WHERE application_id = ? AND tenant_key = ? AND subject_id = ? AND owner_key = ? AND collection = ? AND key = ? AND revision = ? AND deleted = 0 RETURNING key, revision, value_json, blob_key',
174
+ )
175
+ .bind(inline, blobKey, Date.now(), ...values, key, expectedRevision)
176
+ .first()
177
+ if (row === null) {
178
+ if (blobKey && this.#blobs) await this.#blobs.delete(blobKey)
179
+ throw new KitFailure('conflict', 'Document revision conflict')
180
+ }
181
+ // SQL RETURNING is authoritative; do not reread a row that another writer can change.
182
+ if (typeof row !== 'object' || !('revision' in row) || typeof row.revision !== 'number')
183
+ throw new KitFailure('internal', 'Stored document is unreadable')
184
+ const parsed: unknown = JSON.parse(json)
185
+ return { key, revision: row.revision, value: parsed }
186
+ }
187
+ async remove(owner: Actor, collection: string, key: string, expectedRevision: number): Promise<void> {
188
+ identifier(key)
189
+ revision(expectedRevision)
190
+ const row = await this.#database
191
+ .prepare(
192
+ 'UPDATE aether_documents SET revision = revision + 1, value_json = NULL, blob_key = NULL, deleted = 1, updated_at = ? WHERE application_id = ? AND tenant_key = ? AND subject_id = ? AND owner_key = ? AND collection = ? AND key = ? AND revision = ? AND deleted = 0 RETURNING key',
193
+ )
194
+ .bind(Date.now(), ...scope(owner, collection), key, expectedRevision)
195
+ .first()
196
+ if (row === null) throw new KitFailure('conflict', 'Document revision conflict')
197
+ }
198
+ }
package/README.md DELETED
@@ -1,3 +0,0 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.