@thatopen/services 0.28.2 → 0.29.1

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/dist/cli.js CHANGED
@@ -6960,6 +6960,14 @@ class ProjectDocuments {
6960
6960
  projectId: this.projectId
6961
6961
  });
6962
6962
  }
6963
+ /**
6964
+ * The document's DRAFTS: each user's working copy, kept so that closing the
6965
+ * browser never loses work. A draft is not a version — it never enters the
6966
+ * history and never shows in the CDE. See {@link DocumentDrafts}.
6967
+ */
6968
+ drafts(folderId) {
6969
+ return new DocumentDrafts(this.client, () => this.#descriptorFile(folderId));
6970
+ }
6963
6971
  /**
6964
6972
  * The legacy `derivedFileId` link, read from the newest version metadata
6965
6973
  * that carries one. Only `fromFile` looks at it, to migrate the link into
@@ -6990,6 +6998,120 @@ class ProjectDocuments {
6990
6998
  return found ? { id: idOf$1(found) } : void 0;
6991
6999
  }
6992
7000
  }
7001
+ const DRAFT_TAG = "draft:";
7002
+ const DRAFT_MAGIC = "TODRAFT1";
7003
+ class DocumentDrafts {
7004
+ constructor(client, holder) {
7005
+ this.client = client;
7006
+ this.holder = holder;
7007
+ }
7008
+ /** Every user's latest draft, without downloading any. Newest first. */
7009
+ async list() {
7010
+ const holder = await this.holder();
7011
+ if (!holder) return [];
7012
+ const latest = /* @__PURE__ */ new Map();
7013
+ for (const entry of await this.#entries(holder.id)) {
7014
+ const known = latest.get(entry.userId);
7015
+ if (!known || entry.savedAt > known.savedAt)
7016
+ latest.set(entry.userId, { userId: entry.userId, savedAt: entry.savedAt });
7017
+ }
7018
+ return [...latest.values()].sort((a, b) => +b.savedAt - +a.savedAt);
7019
+ }
7020
+ /**
7021
+ * One user's draft, downloaded — or, without `userId`, the newest of anyone.
7022
+ * Null when there is none.
7023
+ */
7024
+ async get(options) {
7025
+ const holder = await this.holder();
7026
+ if (!holder) return null;
7027
+ const entries = (await this.#entries(holder.id)).filter((e) => !options?.userId || e.userId === options.userId).sort((a, b) => +b.savedAt - +a.savedAt);
7028
+ const newest = entries[0];
7029
+ if (!newest) return null;
7030
+ const response = await this.client.downloadHiddenFile(newest.hiddenId);
7031
+ const { meta, blob } = await unwrapDraft(await response.blob());
7032
+ return { userId: newest.userId, savedAt: newest.savedAt, meta, blob };
7033
+ }
7034
+ /**
7035
+ * Stores this user's draft, replacing their previous one: only the latest is
7036
+ * kept. Needs the document to have a descriptor (convert loose files first
7037
+ * with `fromFile`).
7038
+ */
7039
+ async put(blob, options) {
7040
+ const holder = await this.holder();
7041
+ if (!holder) {
7042
+ throw new Error("This folder is not a document (no document.json): drafts need one.");
7043
+ }
7044
+ const tag = draftTag(options.userId);
7045
+ const previous = (await this.#entries(holder.id)).filter((e) => e.userId === options.userId);
7046
+ const created = await this.client.createHiddenFile(
7047
+ await wrapDraft(blob, options.meta ?? {}),
7048
+ holder.id,
7049
+ tag
7050
+ );
7051
+ const hiddenId = created.hiddenFileId;
7052
+ if (!hiddenId) throw new Error("The draft upload answered without a hiddenFileId.");
7053
+ await this.#deleteAll(previous.map((e) => e.hiddenId));
7054
+ return { userId: options.userId, savedAt: savedAtOf(hiddenId) };
7055
+ }
7056
+ /** Deletes every draft of this user (on an explicit save, typically). */
7057
+ async delete(options) {
7058
+ const holder = await this.holder();
7059
+ if (!holder) return;
7060
+ const mine = (await this.#entries(holder.id)).filter((e) => e.userId === options.userId);
7061
+ await this.#deleteAll(mine.map((e) => e.hiddenId));
7062
+ }
7063
+ async #entries(holderId) {
7064
+ const hidden = await this.client.getHiddenFilesByParent(holderId);
7065
+ return hidden.filter((h) => typeof h.tag === "string" && h.tag.startsWith(DRAFT_TAG)).map((h) => {
7066
+ const hiddenId = String(h._id);
7067
+ return {
7068
+ hiddenId,
7069
+ userId: h.tag.slice(DRAFT_TAG.length),
7070
+ savedAt: savedAtOf(hiddenId)
7071
+ };
7072
+ });
7073
+ }
7074
+ async #deleteAll(hiddenIds) {
7075
+ for (const id2 of hiddenIds) {
7076
+ try {
7077
+ await this.client.deleteHiddenFile(id2);
7078
+ } catch {
7079
+ }
7080
+ }
7081
+ }
7082
+ }
7083
+ function draftTag(userId) {
7084
+ const tag = `${DRAFT_TAG}${userId}`;
7085
+ if (!/^[A-Za-z0-9][A-Za-z0-9:._-]{0,49}$/.test(tag)) {
7086
+ throw new Error(`A draft cannot be tagged for user "${userId}": ids must be letters, digits or :._- and short.`);
7087
+ }
7088
+ return tag;
7089
+ }
7090
+ function savedAtOf(objectId) {
7091
+ const seconds = parseInt(objectId.slice(0, 8), 16);
7092
+ return new Date(Number.isFinite(seconds) ? seconds * 1e3 : 0);
7093
+ }
7094
+ async function wrapDraft(blob, meta) {
7095
+ const metaBytes = new TextEncoder().encode(JSON.stringify(meta));
7096
+ const length = new Uint8Array(4);
7097
+ new DataView(length.buffer).setUint32(0, metaBytes.length, true);
7098
+ return new Blob([new TextEncoder().encode(DRAFT_MAGIC), length, metaBytes, blob]);
7099
+ }
7100
+ async function unwrapDraft(blob) {
7101
+ const head = new Uint8Array(await blob.slice(0, DRAFT_MAGIC.length + 4).arrayBuffer());
7102
+ if (new TextDecoder().decode(head.slice(0, DRAFT_MAGIC.length)) !== DRAFT_MAGIC) {
7103
+ return { meta: {}, blob };
7104
+ }
7105
+ const length = new DataView(head.buffer).getUint32(DRAFT_MAGIC.length, true);
7106
+ const start = DRAFT_MAGIC.length + 4;
7107
+ const metaText = await blob.slice(start, start + length).text();
7108
+ let meta = {};
7109
+ try {
7110
+ meta = JSON.parse(metaText);
7111
+ } catch {
7112
+ }
7113
+ return { meta, blob: blob.slice(start + length) };
7114
+ }
6993
7115
  function formatOf(fileName, extension) {
6994
7116
  const fromExt = (extension ?? "").replace(/^\./, "").toLowerCase();
6995
7117
  if (fromExt) return fromExt;
@@ -33556,6 +33678,9 @@ function projectIdFrom(input) {
33556
33678
  if (/^[0-9a-f]{24}$/i.test(trimmed)) return trimmed;
33557
33679
  return trimmed.match(/projects?\/([0-9a-f]{24})/i)?.[1] ?? null;
33558
33680
  }
33681
+ function appIdFrom(input) {
33682
+ return input.trim().match(/apps\/([0-9a-f]{24})/i)?.[1];
33683
+ }
33559
33684
  class ChannelConnection {
33560
33685
  constructor(credentials) {
33561
33686
  this.credentials = credentials;
@@ -33716,12 +33841,12 @@ async function startMcpServer() {
33716
33841
  );
33717
33842
  server.tool(
33718
33843
  "send-app-command",
33719
- "Sends a command to a running That Open app through the platform channel and returns its reply. The app must be open in a browser tab and listening (apps scaffolded by `thatopen create` handle `ping` and `get-loaded-models` out of the box; apps declare their own commands in src/setups/channel.ts). While the user iterates with `thatopen serve`, omit app_id — the dev channel is keyed to the account; pass it once the app is published. `delivered: 0` means no tab of the app has the channel joined, usually because the app is simply not open — ask the user to open or reload the app tab.",
33844
+ "Sends a command to a running That Open app through the platform channel and returns its reply. To USE an app the person has open (rather than build one), pass the URL of their app tab as `project` and send `help` first: the app answers with its own commands and how to use them, and that is the manual to follow. The app must be open in a browser tab and listening (apps scaffolded by `thatopen create` handle `ping` and `get-loaded-models` out of the box; apps declare their own commands in src/setups/channel.ts). While the user iterates with `thatopen serve`, omit app_id — the dev channel is keyed to the account; pass it once the app is published. `delivered: 0` means no tab of the app has the channel joined, usually because the app is simply not open — ask the user to open or reload the app tab.",
33720
33845
  {
33721
- project: stringType().describe("The project id (24 hex characters), or the project's dashboard URL."),
33846
+ project: stringType().describe("The project id (24 hex characters), or the dashboard URL of the app tab: a URL with `/apps/<id>` gives the app_id too."),
33722
33847
  command: stringType().describe('The command name the app handles, e.g. "ping" or "get-loaded-models".'),
33723
33848
  payload: unknownType().optional().describe("JSON payload for the command, if it takes one."),
33724
- app_id: stringType().regex(/^[0-9a-f]{24}$/i).optional().describe("The published app's id. Omit while the app runs under `thatopen serve`."),
33849
+ app_id: stringType().regex(/^[0-9a-f]{24}$/i).optional().describe("The published app's id. Omit while the app runs under `thatopen serve`, or when `project` is a URL that carries it."),
33725
33850
  wait_for_reply: booleanType().optional().describe("Default true. Set false for fire-and-forget notifications."),
33726
33851
  timeout_ms: numberType().int().min(1e3).max(MAX_REPLY_TIMEOUT_MS).optional().describe(`How long to wait for the app's reply. Default ${DEFAULT_REPLY_TIMEOUT_MS}.`)
33727
33852
  },
@@ -33734,7 +33859,7 @@ async function startMcpServer() {
33734
33859
  }
33735
33860
  const result = await connect2().send({
33736
33861
  projectId,
33737
- appId: args.app_id,
33862
+ appId: args.app_id ?? appIdFrom(args.project),
33738
33863
  type: args.command,
33739
33864
  payload: args.payload,
33740
33865
  waitForReply: args.wait_for_reply !== false,