diff --git a/.changeset/history-and-recipes.md b/.changeset/history-and-recipes.md new file mode 100644 index 0000000..07042d5 --- /dev/null +++ b/.changeset/history-and-recipes.md @@ -0,0 +1,7 @@ +--- +'@thatopen/services': minor +--- + +The history and recipes APIs, at services level. `client.history(projectId)` reads and writes a project's connected documents (Revit, AutoCAD…): version timelines ordered by the parent graph, deltas, full models, and change requests (suggestions) with their lifecycle. The flow storage underneath (`bimterop` folders, `revitflow_history.json`, `revitflow_ops.json`, hidden deltas) is fully encapsulated: this surface is the only supported way in, so the layout and the future platform-level API can change without touching callers. Appends take an `expectedLatestGuid` and refuse with `StaleHistoryError` when the head moved; the remaining write race is documented and owned by the agreed platform-level phase 2. + +`client.recipes` is the registry and runner for recipes: scripts that write suggestions in a native application's own language, defined by a manifest (`to`, `inputs`, `runtime`, `settingsSchema`, a self-documenting rules table) plus one `run` function. Running a recipe only queues a suggestion, so trying one is always safe. diff --git a/docs/client/paths.json b/docs/client/paths.json index 7201587..59b4c91 100644 --- a/docs/client/paths.json +++ b/docs/client/paths.json @@ -35,6 +35,10 @@ "path": "src/core/examples/hidden-files.ts", "description": "Hidden file lifecycle — create, get, list by parent, download, and delete." }, + { + "path": "src/core/examples/history.ts", + "description": "History and recipes — a project's connected documents (Revit, AutoCAD…), their version timelines, change requests (suggestions), and recipes: scripts that write suggestions in an app's native language." + }, { "path": "src/core/examples/icons.ts", "description": "Item icon management — upload, download, and remove icons on any item (file, app, or component)." diff --git a/src/core/client.ts b/src/core/client.ts index f3c81cd..40cf59e 100644 --- a/src/core/client.ts +++ b/src/core/client.ts @@ -1,4 +1,6 @@ import { io } from 'socket.io-client'; +import { ProjectHistory } from './history'; +import { Recipes } from './recipes'; import { ExecutionEntity, ExecutionSuscriptionReturnType, @@ -1348,6 +1350,29 @@ export class EngineServicesClient { }); } + // ─── History & Recipes ─────────────────────────────────────────── + + /** + * A project's history of connected documents (Revit models, AutoCAD + * drawings…): their version timelines and their change requests. See + * `src/core/history.ts` — this surface is the only supported way to read + * or write it; the storage underneath is private and will move. + */ + history(projectId: string): ProjectHistory { + return new ProjectHistory(this, projectId); + } + + #recipes?: Recipes; + + /** + * The recipe registry and runner: scripts that write change requests in a + * native application's language. See `src/core/recipes.ts`. + */ + get recipes(): Recipes { + this.#recipes ??= new Recipes(this); + return this.#recipes; + } + // ─── Hidden Files ──────────────────────────────────────────────── /** diff --git a/src/core/examples/history.ts b/src/core/examples/history.ts new file mode 100644 index 0000000..faa25e4 --- /dev/null +++ b/src/core/examples/history.ts @@ -0,0 +1,103 @@ +// description: "History and recipes — a project's connected documents (Revit, AutoCAD…), their version timelines, change requests (suggestions), and recipes: scripts that write suggestions in an app's native language." +import { EngineServicesClient } from '../client'; +import type { Recipe } from '../recipes'; + +// The mental model, in three sentences: +// 1. Every connected document (a Revit model, an AutoCAD drawing) has its +// version history: who synced, when, what changed. +// 2. Nobody writes in somebody else's document. Changes arrive as SUGGESTIONS +// (create / modify / delete native data) that the owner accepts or +// dismisses in the native app. That is why running any of this is safe. +// 3. A RECIPE is a script that writes suggestions in an application's own +// language. Anything that wants to change a Revit document uses a Revit +// recipe to say it in Revit's words. + +export async function readTheProjectHistory( + client: EngineServicesClient, + projectId: string, +) { + const history = client.history(projectId); + + // Which documents are connected, and from which application. + const docs = await history.docs(); + for (const { source, docId } of docs) { + const doc = history.doc(source, docId); + + // The timeline is ordered by the version graph, oldest first, with + // `ordinal` filled in. The last entry is the head. + const timeline = await doc.timeline(); + const head = timeline[timeline.length - 1]; + console.log( + `${source}-${docId}: ${timeline.length} versions, head by ${head?.author}`, + ); + + // A version's geometry: full exports ARE the model file; ordinary + // versions carry a hidden delta. + if (head?.deltaId) await doc.delta(head); + else if (head?.full) await doc.model(); + + // The change requests waiting for the document's owner. + const pending = await doc.pendingSuggestions(); + console.log(`${pending.length} suggestions pending`); + } +} + +// A recipe is its manifest plus one function. The settingsSchema is what apps +// render as a form — anything naming an EXISTING thing must be an enum the +// app fills, so people pick instead of typing names. +const pointsToBlocks: Recipe<{ layer: string; block: string }> = { + name: 'points-to-autocad-blocks', + description: 'Places an AutoCAD block on every input point.', + to: 'autocad', + inputs: ['points'], + runtime: 'browser', + settingsSchema: { + type: 'object', + properties: { + layer: { type: 'string' }, + block: { type: 'string' }, + }, + required: ['layer', 'block'], + }, + rules: [{ reads: 'input points', proposes: 'one block per point' }], + async run({ input, settings }) { + const points = input as { x: number; y: number; z: number }[]; + // Build the suggestion payload (a .frag with envelope revitflow/op@2, + // one item per change, attributes in the TARGET's own vocabulary). + // The payload builder lives in the fragments toolkit; omitted here. + const payload = await buildOpFrag(points, settings); + return { + title: `Place ${points.length} ${settings.block} blocks`, + verb: 'add', + targets: points.length, + payload, + ruleTallies: [{ read: points.length, proposed: points.length }], + }; + }, +}; + +export async function runARecipe( + client: EngineServicesClient, + projectId: string, +) { + client.recipes.register(pointsToBlocks); + + // Catalogs filter by target application: "who can speak autocad?". + client.recipes.list({ to: 'autocad' }); + + // Running is always safe: it only queues a suggestion on the target + // document. Nothing changes in AutoCAD until its owner applies it there. + const { suggestion } = await client.recipes.run('points-to-autocad-blocks', { + projectId, + target: { source: 'autocad', docId: 'site-plan' }, + input: [{ x: 0, y: 0, z: 0 }], + settings: { layer: 'DETECTORS', block: 'SIEMENS-FDO181' }, + author: 'recipe:points-to-autocad-blocks', + }); + console.log(`queued suggestion ${suggestion.opId}`); +} + +declare function buildOpFrag( + points: { x: number; y: number; z: number }[], + settings: { layer: string; block: string }, +): Promise; diff --git a/src/core/history.ts b/src/core/history.ts new file mode 100644 index 0000000..4f5a781 --- /dev/null +++ b/src/core/history.ts @@ -0,0 +1,710 @@ +/** + * The history API: documents, versions and change requests, with the flow + * storage encapsulated. + * + * The vocabulary is deliberately small and BIM-shaped: + * - A DOC is a connected native document (a Revit model, an AutoCAD drawing). + * - A VERSION is one sync of it: who, when, what changed. + * - A SUGGESTION is a change request someone queued for the doc's owner to + * accept or dismiss in the native app. Nobody writes in somebody else's + * document. + * + * EVERYTHING BELOW THIS SURFACE IS PRIVATE. Today the data lives as plain + * project files (the `bimterop` folder, `revitflow_history.json`, + * `revitflow_ops.json`, hidden deltas) written by the flow plugins — see + * PROTOCOL.md in the flow repo. This module is the ONLY place in consumer + * code allowed to know that: when the platform grows a first-class history + * API, or the layout moves into hidden storage, this file changes and its + * callers do not. + * + * KNOWN LIMIT, deliberate: appending re-uploads the whole history file, and + * the backend has no compare-and-swap, so two writers syncing the same doc at + * the same instant can lose one version silently. `sync` narrows the window + * by re-reading right before it writes and refusing when the head moved + * (StaleHistoryError), but only a server-side append can close it. That is + * the agreed phase 2. + */ +import type { EngineServicesClient } from './client'; + +/** One element mutation inside a version. Wire-compatible with the plugins. */ +export interface FlowChange { + /** create | update | delete. */ + type: 'create' | 'update' | 'delete' | string; + /** The native application's own id for the element (Revit UniqueId, AutoCAD handle). */ + uniqueId: string; +} + +/** Per-version change tallies. */ +export interface FlowCounts { + added: number; + modified: number; + removed: number; +} + +/** + * One sync of a document. Field names are the wire format the plugins write + * (`revitflow_history.json`); do not rename them. + */ +export interface FlowVersion { + /** Identity, minted by the writer. */ + guid: string; + /** The versions this one was built on; absent on a root. First entry is the continued line. */ + parents?: string[] | null; + /** The CDE's storage version number, when there is one. Data, never identity. */ + version?: number; + /** What the author said it was for, when they said anything. */ + message?: string; + author: string; + /** Which application wrote it: 'revit', 'autocad', 'rhino'… */ + source: string; + /** ISO UTC. */ + timestamp: string; + changes: FlowChange[]; + counts: FlowCounts; + /** Hidden-file id of the delta .frag; absent on the baseline and geometry-less versions. */ + deltaId?: string; + /** Hidden-file id of the per-element geometry fingerprints, when the writer keeps them. */ + geomId?: string; + /** Platform user id of the committer, when the writer knew it. */ + userId?: string; + /** This version re-exported the whole model (the visible .frag shows it as of here). */ + full?: boolean; + compact?: boolean; + /** Suggestions this version applied (their opIds). */ + appliedOps?: string[]; + /** Position in the timeline, derived from the parent graph when read. Never stored. */ + ordinal?: number; +} + +/** Wire shape of `revitflow_history.json`. */ +interface FlowHistoryFile { + model: string; + commits: FlowVersion[]; +} + +/** + * A change request queued for a document. Wire-compatible with + * `revitflow_ops.json` (schema `revitflow/ops@1`). + */ +export interface FlowSuggestion { + opId: string; + /** Groups the suggestions one authored action produced across several docs. */ + batchId?: string; + /** Hidden-file id of the payload .frag (envelope `revitflow/op@2`). */ + fragId?: string; + title?: string; + /** add | set | remove | mixed. */ + verb?: string; + author?: string; + userId?: string; + createdAt?: string; + /** The version this was authored against, by guid. */ + baseCommitGuid?: string; + /** The storage version that version landed in, when known. Data only. */ + baseCommit?: number; + /** How many elements it targets. */ + targets?: number; + state?: 'pending' | 'applied' | 'discarded' | string; + /** The native version that materialised it, once applied (storage version number). */ + appliedInCommit?: number | null; + /** Native ids it touched or created, written by the plugin when it applied. */ + affectedIds?: string[]; +} + +/** Wire shape of `revitflow_ops.json`. */ +interface FlowOpsFile { + schema?: string; + ops: FlowSuggestion[]; +} + +/** A connected document, as listed from the project. */ +export interface FlowDocInfo { + /** The native application that owns it: 'revit', 'autocad'… */ + source: string; + /** The document's own id within that application. */ + docId: string; + /** The visible model file's name, when one exists yet. */ + modelName?: string; +} + +/** What `sync` needs: the change list plus whatever the writer knows. */ +export interface SyncInput { + author: string; + /** Which application/tool is writing: 'revit', 'rhino', 'recipe'… */ + source: string; + changes: FlowChange[]; + message?: string; + /** Delta geometry for this version, uploaded as a hidden file. */ + delta?: Blob; + /** Re-upload of the whole model; marks the version `full`. */ + model?: Blob; + /** Platform user id, when known. */ + userId?: string; + /** Suggestions this version applied. */ + appliedOps?: string[]; + /** + * The head you built on. When given and the stored head differs, `sync` + * refuses with {@link StaleHistoryError} instead of forking silently. + */ + expectedLatestGuid?: string; +} + +/** What `suggest` needs. */ +export interface SuggestInput { + title: string; + /** add | set | remove | mixed. */ + verb: string; + author: string; + /** The suggestion's .frag payload (envelope `revitflow/op@2`), when it has one. */ + payload?: Blob; + targets?: number; + batchId?: string; + userId?: string; + /** The version it was authored against. */ + baseCommitGuid?: string; +} + +/** The head moved while a writer prepared its version. Re-read and retry. */ +export class StaleHistoryError extends Error { + constructor( + readonly expectedGuid: string, + readonly actualGuid: string | undefined, + ) { + super( + 'The document history advanced while this version was being prepared: ' + + `expected head ${expectedGuid}, found ${actualGuid ?? 'none'}. ` + + 'Read the timeline again and rebuild on the new head.', + ); + this.name = 'StaleHistoryError'; + } +} + +// ─── The storage layout, stated once ───────────────────────────────────────── +// PROTOCOL.md §1: project → `bimterop` → `-` → files. +const ROOT_FOLDER = 'bimterop'; +const HISTORY_FILE = 'revitflow_history.json'; +const OPS_FILE = 'revitflow_ops.json'; +const OPS_SCHEMA = 'revitflow/ops@1'; +const LEGACY_MODEL_FILE = 'revitflow_model.frag'; +// Internal bookkeeping files that are NOT the model. +const INTERNAL_PREFIX = 'revitflow_'; +const versionTag = () => `v-${Date.now()}`; + +interface StoredFile { + id: string; + name: string; +} + +/** Normalizes the `_id`/`id` split the item endpoints disagree on. */ +function idOf(item: { _id?: unknown; id?: unknown }): string { + return String(item._id ?? item.id ?? ''); +} + +/** + * The history of one project. Get it from {@link EngineServicesClient.history}. + */ +export class ProjectHistory { + #rootFolderId?: string; + + constructor( + private readonly client: EngineServicesClient, + readonly projectId: string, + ) {} + + /** The project's connected documents. */ + async docs(): Promise { + const root = await this.#root(); + if (!root) return []; + const folders = await this.client.listFolders({ + parentFolderId: root, + projectId: this.projectId, + }); + const docs: FlowDocInfo[] = []; + for (const folder of folders) { + const name = String(folder.name ?? ''); + const dash = name.indexOf('-'); + if (dash <= 0) continue; + docs.push({ + source: name.slice(0, dash), + docId: name.slice(dash + 1), + modelName: undefined, + }); + } + return docs; + } + + /** One document's history. `source` and `docId` as {@link docs} lists them. */ + doc(source: string, docId: string): FlowDoc { + return new FlowDoc(this.client, this, source, docId); + } + + /** @internal The `bimterop` folder id, resolved once. */ + async rootFolderId(create = false): Promise { + if (this.#rootFolderId) return this.#rootFolderId; + const found = await this.#root(); + if (found) return found; + if (!create) return undefined; + const made = await this.client.createFolder( + ROOT_FOLDER, + undefined, + this.projectId, + ); + this.#rootFolderId = idOf(made as never); + return this.#rootFolderId; + } + + async #root(): Promise { + if (this.#rootFolderId) return this.#rootFolderId; + const folders = await this.client.listFolders({ + projectId: this.projectId, + }); + const root = folders.find( + (f) => String(f.name ?? '') === ROOT_FOLDER && !f.parentId, + ); + if (root) this.#rootFolderId = idOf(root as never); + return this.#rootFolderId; + } +} + +/** + * One connected document: its timeline, its model, and its suggestions. + */ +export class FlowDoc { + #folderId?: string; + + constructor( + private readonly client: EngineServicesClient, + private readonly project: ProjectHistory, + readonly source: string, + readonly docId: string, + ) {} + + // ─── Reading ─────────────────────────────────────────────────────────────── + + /** + * The document's versions, oldest first, ordered by the parent graph (not + * by array position, which concurrent writers do not keep meaningful). + * Each version gets its `ordinal` filled in. + */ + async timeline(): Promise { + const history = await this.#readHistory(); + return orderByGraph(history?.commits ?? []); + } + + /** The head of the timeline, or null before the first sync. */ + async latest(): Promise { + const timeline = await this.timeline(); + return timeline.length ? timeline[timeline.length - 1] : null; + } + + /** + * A version's delta geometry as a .frag. The baseline (`full`) versions have + * no delta: their geometry IS the model file — use {@link model}. + */ + async delta(version: FlowVersion): Promise { + if (!version.deltaId) { + throw new Error( + `Version ${version.guid} carries no delta: it is a full export — read model() instead.`, + ); + } + const response = await this.client.downloadHiddenFile(version.deltaId); + return await response.blob(); + } + + /** The visible model .frag as last fully exported. */ + async model(): Promise { + const file = await this.#modelFile(); + if (!file) { + throw new Error( + `Document ${this.source}-${this.docId} has no model file yet.`, + ); + } + const response = await this.client.downloadFile(file.id); + return await response.blob(); + } + + // ─── Writing versions ───────────────────────────────────────────────────── + + /** + * Appends one version: uploads the geometry it carries, then the updated + * history. Give `expectedLatestGuid` whenever you built on a head you read, + * so a concurrent writer surfaces as {@link StaleHistoryError} instead of a + * silent fork. + */ + async sync(input: SyncInput): Promise { + const folderId = await this.#folder(true); + let history = (await this.#readHistory()) ?? { + model: this.docId, + commits: [], + }; + + const head = lastByGraph(history.commits); + if ( + input.expectedLatestGuid !== undefined && + head?.guid !== input.expectedLatestGuid + ) { + throw new StaleHistoryError(input.expectedLatestGuid, head?.guid); + } + + const version: FlowVersion = { + guid: cryptoRandomGuid(), + ...(head ? { parents: [head.guid] } : {}), + author: input.author, + source: input.source, + timestamp: new Date().toISOString(), + ...(input.message ? { message: input.message } : {}), + ...(input.userId ? { userId: input.userId } : {}), + ...(input.appliedOps?.length ? { appliedOps: input.appliedOps } : {}), + changes: input.changes, + counts: tally(input.changes), + }; + + if (input.model) { + await this.#writeModel(folderId, input.model); + version.full = true; + } + if (input.delta) { + const modelFile = await this.#modelFile(); + if (!modelFile) { + throw new Error( + 'A delta needs the model file to hang from: sync a full model first.', + ); + } + const hidden = await this.client.createHiddenFile( + input.delta, + modelFile.id, + ); + version.deltaId = String( + (hidden as { _id?: unknown; id?: unknown })._id ?? + (hidden as { id?: unknown }).id, + ); + } + + // Re-read right before writing: the smallest window this storage allows. + const fresh = await this.#readHistory(); + if (fresh) { + history = fresh; + const freshHead = lastByGraph(history.commits); + if ( + input.expectedLatestGuid !== undefined && + freshHead?.guid !== input.expectedLatestGuid + ) { + throw new StaleHistoryError(input.expectedLatestGuid, freshHead?.guid); + } + if (freshHead && !version.parents) version.parents = [freshHead.guid]; + } + history.commits.push(version); + await this.#writeJson(folderId, HISTORY_FILE, history); + return version; + } + + // ─── Suggestions (change requests) ──────────────────────────────────────── + + /** Every suggestion in the document's queue, pending or resolved. */ + async suggestions(): Promise { + const ops = await this.#readOps(); + return ops?.ops ?? []; + } + + /** The suggestions still waiting for the owner, oldest first. */ + async pendingSuggestions(): Promise { + return (await this.suggestions()).filter((op) => op.state === 'pending'); + } + + /** + * Queues a change request for this document. The payload, when given, is + * uploaded as a hidden file under the suggestions index. + */ + async suggest(input: SuggestInput): Promise { + const folderId = await this.#folder(true); + const index = (await this.#readOps()) ?? { schema: OPS_SCHEMA, ops: [] }; + const indexFile = await this.#file(OPS_FILE); + + const suggestion: FlowSuggestion = { + opId: cryptoRandomGuid(), + title: input.title, + verb: input.verb, + author: input.author, + createdAt: new Date().toISOString(), + state: 'pending', + ...(input.targets !== undefined ? { targets: input.targets } : {}), + ...(input.batchId ? { batchId: input.batchId } : {}), + ...(input.userId ? { userId: input.userId } : {}), + ...(input.baseCommitGuid + ? { baseCommitGuid: input.baseCommitGuid } + : {}), + }; + + if (input.payload) { + // The payload hangs from the index file, so it exists first. + const holder = + indexFile ?? + (await this.#createJson(folderId, OPS_FILE, index)); + const hidden = await this.client.createHiddenFile( + input.payload, + holder.id, + ); + suggestion.fragId = String( + (hidden as { _id?: unknown; id?: unknown })._id ?? + (hidden as { id?: unknown }).id, + ); + } + + const fresh = (await this.#readOps()) ?? index; + fresh.schema ??= OPS_SCHEMA; + fresh.ops.push(suggestion); + await this.#writeJson(folderId, OPS_FILE, fresh); + return suggestion; + } + + /** A suggestion's .frag payload. */ + async suggestionPayload(suggestion: FlowSuggestion): Promise { + if (!suggestion.fragId) { + throw new Error(`Suggestion ${suggestion.opId} carries no payload.`); + } + const response = await this.client.downloadHiddenFile(suggestion.fragId); + return await response.blob(); + } + + /** + * Marks a suggestion applied (naming the version that materialised it) or + * discarded. The native plugins do this themselves when they apply; apps + * use it for `discarded`. + */ + async resolveSuggestion( + opId: string, + outcome: + | { state: 'applied'; appliedInCommit?: number; affectedIds?: string[] } + | { state: 'discarded' }, + ): Promise { + const folderId = await this.#folder(false); + if (!folderId) throw new Error('This document has no suggestion queue.'); + const index = await this.#readOps(); + const suggestion = index?.ops.find((op) => op.opId === opId); + if (!index || !suggestion) { + throw new Error(`No suggestion ${opId} in this document's queue.`); + } + Object.assign(suggestion, outcome); + await this.#writeJson(folderId, OPS_FILE, index); + return suggestion; + } + + // ─── The layout, below the surface ──────────────────────────────────────── + + get #folderName(): string { + return `${this.source}-${this.docId}`; + } + + async #folder(create: boolean): Promise { + if (this.#folderId) return this.#folderId; + const rootId = await this.project.rootFolderId(create); + if (!rootId) { + throw new Error( + `Project ${this.project.projectId} has no connected documents yet.`, + ); + } + const folders = await this.client.listFolders({ + parentFolderId: rootId, + projectId: this.project.projectId, + }); + const found = folders.find( + (f) => String(f.name ?? '') === this.#folderName, + ); + if (found) { + this.#folderId = idOf(found as never); + return this.#folderId; + } + if (!create) { + throw new Error( + `Document ${this.#folderName} does not exist in this project.`, + ); + } + const made = await this.client.createFolder( + this.#folderName, + rootId, + this.project.projectId, + ); + this.#folderId = idOf(made as never); + return this.#folderId; + } + + async #files(): Promise { + const folderId = await this.#folder(false); + const items = await this.client.listFiles({ folderId }); + return items.map((item) => ({ + id: idOf(item as never), + name: String((item as { name?: unknown }).name ?? ''), + })); + } + + async #file(name: string): Promise { + try { + return (await this.#files()).find((f) => f.name === name); + } catch { + return undefined; + } + } + + async #modelFile(): Promise { + try { + const files = await this.#files(); + return ( + files.find((f) => f.name === LEGACY_MODEL_FILE) ?? + files.find( + (f) => + f.name.endsWith('.frag') && !f.name.startsWith(INTERNAL_PREFIX), + ) + ); + } catch { + return undefined; + } + } + + async #readJson(name: string): Promise { + const file = await this.#file(name); + if (!file) return undefined; + const response = await this.client.downloadFile(file.id); + const text = await response.text(); + if (!text.trim()) return undefined; + return JSON.parse(text) as T; + } + + #readHistory(): Promise { + return this.#readJson(HISTORY_FILE); + } + + #readOps(): Promise { + return this.#readJson(OPS_FILE); + } + + async #writeJson( + folderId: string, + name: string, + value: unknown, + ): Promise { + const blob = new Blob([JSON.stringify(value)], { + type: 'application/json', + }); + const existing = await this.#file(name); + if (existing) { + await this.client.updateFile(existing.id, { + file: blob, + versionTag: versionTag(), + }); + return existing; + } + return await this.#createJson(folderId, name, value); + } + + async #createJson( + folderId: string, + name: string, + value: unknown, + ): Promise { + const blob = new Blob([JSON.stringify(value)], { + type: 'application/json', + }); + const created = await this.client.createFile({ + file: blob, + name, + versionTag: versionTag(), + parentFolderId: folderId, + projectId: this.project.projectId, + }); + const item = (created as { item?: { _id?: unknown; id?: unknown } }).item; + return { id: idOf((item ?? created) as never), name }; + } + + async #writeModel(folderId: string, model: Blob): Promise { + const existing = await this.#modelFile(); + if (existing) { + await this.client.updateFile(existing.id, { + file: model, + versionTag: versionTag(), + }); + return existing; + } + const name = `${this.#folderName}.frag`; + const created = await this.client.createFile({ + file: model, + name, + versionTag: versionTag(), + parentFolderId: folderId, + projectId: this.project.projectId, + }); + const item = (created as { item?: { _id?: unknown; id?: unknown } }).item; + return { id: idOf((item ?? created) as never), name }; + } +} + +// ─── Graph helpers ─────────────────────────────────────────────────────────── + +function tally(changes: FlowChange[]): FlowCounts { + const counts: FlowCounts = { added: 0, modified: 0, removed: 0 }; + for (const change of changes) { + if (change.type === 'create') counts.added++; + else if (change.type === 'delete') counts.removed++; + else counts.modified++; + } + return counts; +} + +/** + * Orders versions by walking the parent graph from the root along first + * parents, breaking forks by timestamp — array position is not meaningful + * once more than one application writes. Fills `ordinal`. + */ +export function orderByGraph(versions: FlowVersion[]): FlowVersion[] { + if (versions.length <= 1) { + versions.forEach((v, i) => (v.ordinal = i)); + return [...versions]; + } + const byGuid = new Map(versions.map((v) => [v.guid, v])); + const children = new Map(); + const roots: FlowVersion[] = []; + for (const version of versions) { + const parent = version.parents?.[0]; + if (parent && byGuid.has(parent)) { + const list = children.get(parent) ?? []; + list.push(version); + children.set(parent, list); + } else { + roots.push(version); + } + } + const byTime = (a: FlowVersion, b: FlowVersion) => + String(a.timestamp).localeCompare(String(b.timestamp)); + roots.sort(byTime); + + const ordered: FlowVersion[] = []; + const stack = [...roots].reverse(); + const seen = new Set(); + while (stack.length) { + const version = stack.pop()!; + if (seen.has(version.guid)) continue; + seen.add(version.guid); + ordered.push(version); + const next = (children.get(version.guid) ?? []).sort(byTime); + for (let i = next.length - 1; i >= 0; i--) stack.push(next[i]); + } + // Anything unreachable (broken parents) still shows, by time, at the end. + for (const version of versions) { + if (!seen.has(version.guid)) ordered.push(version); + } + ordered.forEach((v, i) => (v.ordinal = i)); + return ordered; +} + +function lastByGraph(versions: FlowVersion[]): FlowVersion | undefined { + const ordered = orderByGraph(versions); + return ordered[ordered.length - 1]; +} + +function cryptoRandomGuid(): string { + const anyCrypto = globalThis.crypto as { randomUUID?: () => string }; + if (anyCrypto?.randomUUID) return anyCrypto.randomUUID(); + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => { + const r = (Math.random() * 16) | 0; + return (c === 'x' ? r : (r & 0x3) | 0x8).toString(16); + }); +} diff --git a/src/core/recipes.ts b/src/core/recipes.ts new file mode 100644 index 0000000..d44aa18 --- /dev/null +++ b/src/core/recipes.ts @@ -0,0 +1,163 @@ +/** + * Recipes: scripts that write change requests (suggestions) in a native + * application's own language. + * + * A recipe's whole contract is one sentence: it reads some input — another + * document's version, platform elements, points, a spreadsheet, anything — + * and queues a SUGGESTION for a target document, expressed in that + * application's native vocabulary (families, types, levels, blocks, layers). + * Nothing applies until the document's owner accepts it in the native app, + * which is why trying a recipe is always safe. + * + * A recipe is defined by its contract, not by where it runs: `runtime` + * declares the default ('browser' runs where the caller is; 'cloud' is for + * recipes that must run with nobody in front). This module is the local + * registry and runner; the platform-level catalog (recipes as published + * items) is the agreed phase 2 and will sit on top of the same manifest. + */ +import type { EngineServicesClient } from './client'; +import type { FlowDoc, FlowSuggestion } from './history'; + +/** What a recipe declares about itself. Shown in catalogs, checked by apps. */ +export interface RecipeManifest { + /** Machine name, unique in a catalog: 'rhino-points-to-revit-columns'. */ + name: string; + /** The one sentence a non-programmer reads: what it converts into what. */ + description: string; + /** The native application whose language it writes: 'revit', 'autocad'… */ + to: string; + /** The input kinds it understands: 'rhino-model', 'platform-elements', 'points', 'csv'… */ + inputs: string[]; + /** Where it runs by default. Browser unless it must run unattended. */ + runtime?: 'browser' | 'cloud'; + /** + * JSON Schema for `settings`. Apps generate the settings form from it, so + * anything that names an EXISTING thing (a target document, a family, a + * level) must be an enum the app can fill — people pick, they never type + * names of things that exist. + */ + settingsSchema?: Record; + /** + * The self-documentation an app renders: a "reads → proposes" table whose + * rows are RULES (not objects), so a dry run can fill it with real tallies. + */ + rules?: { reads: string; proposes: string }[]; +} + +/** Everything a recipe's `run` gets to work with. */ +export interface RecipeContext> { + client: EngineServicesClient; + projectId: string; + /** The document the suggestion is for. Read its timeline/model if needed. */ + targetDoc: FlowDoc; + /** The declared input, already fetched by the caller (bytes, ids, rows…). */ + input: unknown; + settings: Settings; +} + +/** What a recipe's `run` returns: the suggestion, ready to queue. */ +export interface RecipeResult { + title: string; + /** add | set | remove | mixed. */ + verb: string; + /** The .frag payload (envelope `revitflow/op@2`), when the recipe builds one. */ + payload?: Blob; + targets?: number; + /** Per-rule tallies matching `manifest.rules`, filled by the run. */ + ruleTallies?: { read: number; proposed: number }[]; +} + +/** A recipe: its manifest plus the function that writes the suggestion. */ +export interface Recipe> + extends RecipeManifest { + run(context: RecipeContext): Promise; +} + +/** What {@link Recipes.run} returns: the queued suggestion plus the tallies. */ +export interface RecipeRunOutcome { + suggestion: FlowSuggestion; + result: RecipeResult; +} + +/** + * The local recipe registry and runner. Reach it as `client.recipes`. + */ +export class Recipes { + readonly #registry = new Map>(); + + constructor(private readonly client: EngineServicesClient) {} + + /** Registers a recipe. One per name; a second with the same name throws. */ + register(recipe: Recipe): void { + if (this.#registry.has(recipe.name)) { + throw new Error(`A recipe named "${recipe.name}" is already registered.`); + } + this.#registry.set(recipe.name, recipe as Recipe); + } + + /** The registered recipes, optionally filtered by target application. */ + list(filter?: { to?: string; input?: string }): RecipeManifest[] { + const all = [...this.#registry.values()]; + return all.filter( + (recipe) => + (!filter?.to || recipe.to === filter.to) && + (!filter?.input || recipe.inputs.includes(filter.input)), + ); + } + + /** A registered recipe by name, or undefined. */ + get(name: string): RecipeManifest | undefined { + return this.#registry.get(name); + } + + /** + * Runs a recipe and queues its suggestion on the target document. Safe to + * call freely: nothing touches the native application until its owner + * accepts the suggestion there. + */ + async run( + name: string, + options: { + projectId: string; + /** The target document, as `client.history(projectId).docs()` lists it. */ + target: { source: string; docId: string }; + input?: unknown; + settings?: S; + author: string; + userId?: string; + }, + ): Promise { + const recipe = this.#registry.get(name) as Recipe | undefined; + if (!recipe) throw new Error(`No recipe named "${name}" is registered.`); + if (recipe.to !== options.target.source) { + throw new Error( + `Recipe "${name}" writes ${recipe.to}, but the target document belongs to ${options.target.source}.`, + ); + } + + const targetDoc = this.client + .history(options.projectId) + .doc(options.target.source, options.target.docId); + const base = await targetDoc.latest(); + + const result = await recipe.run({ + client: this.client, + projectId: options.projectId, + targetDoc, + input: options.input, + settings: (options.settings ?? {}) as S, + }); + + const suggestion = await targetDoc.suggest({ + title: result.title, + verb: result.verb, + author: options.author, + payload: result.payload, + targets: result.targets, + userId: options.userId, + baseCommitGuid: base?.guid, + }); + + return { suggestion, result }; + } +} diff --git a/src/index.ts b/src/index.ts index f84c122..6b0bd07 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,8 @@ export * from './core/client'; export * from './core/platform-client'; export * from './core/channel'; +export * from './core/history'; +export * from './core/recipes'; export * from './core/request-error'; export * from './types/items'; export * from './types/base';