@syncmatters/script-api 1.0.21 → 1.0.23

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.
@@ -3,8 +3,9 @@
3
3
  A workspace pulled by `sm` mirrors platform script files locally and adds editor tooling.
4
4
 
5
5
  ```
6
- files/ # script source (.mjs, .json, …) — EDIT and push via sm push
7
- meta/ # one *.meta.json sidecar per file under files/ — EDIT and push
6
+ files/Scripts/ # integration scripts + sync logic (.mjs, .json, …) — EDIT and push via sm push
7
+ files/Connectors/ # connector source, one folder per connector — EDIT and push
8
+ meta/ # one *.meta.json sidecar per file under files/ (same sub-path) — EDIT and push
8
9
  syncs/<sync_uid>/ # sync.config.json — GENERATED read-only snapshots (sm pull sync)
9
10
  groups/<group_uid>/ # group.config.json — GENERATED read-only snapshots
10
11
  types/ # typed-connection .d.ts — GENERATED (sm types)
@@ -18,7 +19,9 @@ AGENTS.md # CLI-generated agent guide (managed block + your notes belo
18
19
 
19
20
  | Path | Agent may edit? | `sm push`? |
20
21
  | --- | --- | --- |
21
- | `files/**` | Yes (when tasked) | Yes (with sidecar) |
22
+ | `files/Scripts/**` | Yes (when tasked) | Yes (with sidecar) |
23
+ | `files/Connectors/**` | Yes (connector work only) | Yes (with sidecar) |
24
+ | any other top-level folder under `files/` (`Modules/`, `Services/`, …) | **No** — the platform rejects it | **No** |
22
25
  | `meta/**` | Yes | Yes (meta fields) |
23
26
  | `syncs/**/sync.config.json` | **No** | **No** |
24
27
  | `groups/**/group.config.json` | **No** | **No** |
@@ -28,17 +28,21 @@ The **entry** script's sidecar lists every file the platform must stage for that
28
28
 
29
29
  ```json
30
30
  "module_script_file_paths": [
31
- "Services/AcmeSync/acme-filter.mjs",
32
- "Services/AcmeSync/acme-utils.mjs"
31
+ "Scripts/AcmeSync/acme-filter.mjs",
32
+ "Scripts/AcmeSync/acme-utils.mjs"
33
33
  ]
34
34
  ```
35
35
 
36
+ Paths are FullNames — the path under `files/` without the `files/` prefix. Script modules always
37
+ start with `Scripts/`; only connector code lives under `Connectors/`. The platform rejects any
38
+ other top-level folder.
39
+
36
40
  `sm push` maps paths to platform file ids from workspace state. Every referenced path must exist
37
41
  and be tracked — unresolved references block the wiring change for that file.
38
42
 
39
43
  ## Linking sync logic to a sync
40
44
 
41
- 1. Push the module file(s) under `files/` with correct sidecars.
45
+ 1. Push the module file(s) under `files/Scripts/` with correct sidecars.
42
46
  2. In the **web UI**, open the sync configuration and attach the script file as custom logic
43
47
  (sets `script_file_id` on the platform).
44
48
  3. Locally, run `sm pull sync` and read `syncs/<sync_uid>/sync.config.json`:
@@ -53,7 +57,8 @@ not a push payload.
53
57
 
54
58
  A new file under `files/` is created on the platform only when:
55
59
 
56
- 1. It has a `meta/` sidecar, and
57
- 2. You `sm push` it.
60
+ 1. It sits under `files/Scripts/` (or `files/Connectors/<Name>/` for connector code),
61
+ 2. It has a `meta/` sidecar, and
62
+ 3. You `sm push` it.
58
63
 
59
64
  No sidecar → no push.
@@ -5,7 +5,8 @@
5
5
  ```
6
6
  sm pull # scripts + meta pins (+ syncs/groups when scoped)
7
7
  sm pull sync # optional: refresh sync/group snapshots only
8
- → edit files/ and meta/ only
8
+ sm sync fields --source <conn>:<obj> --dest <conn>:<obj> # both connections must resolve — stop and ask if one is missing
9
+ → edit files/Scripts/ and meta/Scripts/ only (files/Connectors/ for connector work)
9
10
  → sm validate
10
11
  → sm status / sm diff
11
12
  → sm push -m "describe change" --yes
@@ -13,8 +14,11 @@ sm pull sync # optional: refresh sync/group snapshots only
13
14
 
14
15
  - **Scripts only:** `sm push` uploads changed files under `files/` (and meta sidecars). It does
15
16
  **not** upload `syncs/` or `groups/`.
16
- - **Sync config changes:** `changes/*.sync-changes.json` → `sm sync draft` → human review →
17
- `sm sync push` (needs `syncs:write`). Agents push only on human instruction.
17
+ - **Sync config changes:** confirm every `connection_uid` with `sm sync fields` first — if it
18
+ reports an unknown connection or object, stop and ask the human to create the connection,
19
+ select objects and refresh metadata; do not author mappings from connector tests, OpenAPI
20
+ specs, another workspace or memory. Then `changes/*.sync-changes.json` → `sm sync draft` →
21
+ human review → `sm sync push` (needs `syncs:write`). Agents push only on human instruction.
18
22
  - **Reading snapshots:** use `syncs/` and `groups/` for context before editing the wired module
19
23
  in `files/`.
20
24
 
@@ -16,6 +16,8 @@
16
16
  | Editing `sync.config.json` to fix mappings | Change sync in web UI; re-pull snapshots |
17
17
  | Pushing `syncs/` or `groups/` | Not supported — push only `files/` + `meta/` |
18
18
  | Plain object default export for sync logic | **Class** default export; platform uses `new` |
19
+ | New file outside `files/Scripts/` (e.g. `files/Modules/…`) | Platform rejects it — script code lives under `files/Scripts/`, connectors under `files/Connectors/` |
20
+ | Authoring mappings for a connection that does not exist yet | Stop; ask the human to create the connection, then map only what `sm sync fields` prints |
19
21
  | Missing meta sidecar on new file | Add `meta/.../*.meta.json` before push |
20
22
  | Unwired `import` | Add path to `module_script_file_paths` in sidecar |
21
23
  | Running sync logic locally | Types check locally; execution is platform-only |
@@ -15,6 +15,7 @@ export declare enum ErrorCode {
15
15
  InvalidRelationshipId = "SCRIPT_INVALID_RELATIONSHIP_ID",
16
16
  InvalidRelationshipType = "SCRIPT_INVALID_RELATIONSHIP_TYPE",
17
17
  ResourceAlreadyClosed = "SCRIPT_RESOURCE_ALREADY_CLOSED",
18
+ FileProviderShallowClone = "SCRIPT_FILE_PROVIDER_SHALLOW_CLONE",
18
19
  InvalidEmailRecipients = "SCRIPT_INVALID_EMAIL_RECIPIENTS",
19
20
  InvalidEmailSubject = "SCRIPT_INVALID_EMAIL_SUBJECT",
20
21
  InvalidEmailBody = "SCRIPT_INVALID_EMAIL_BODY",
@@ -1,5 +1,7 @@
1
1
  import { FileProvider } from "./file-provider.js";
2
+ import { kIsClosed } from "./file-provider-internal.js";
2
3
  export declare class FileProviderBuffer implements FileProvider {
4
+ #private;
3
5
  constructor(source: Buffer);
4
6
  length(): Promise<number>;
5
7
  stream(encoding?: "utf8" | "utf16le" | "ucs2" | "latin1"): Promise<NodeJS.ReadableStream>;
@@ -7,5 +9,8 @@ export declare class FileProviderBuffer implements FileProvider {
7
9
  blob(fileName?: string): Promise<Blob>;
8
10
  save(path?: string): Promise<string>;
9
11
  close(): Promise<void>;
12
+ clone(): FileProviderBuffer;
13
+ equals(other: FileProvider): Promise<boolean>;
14
+ [kIsClosed](): boolean;
10
15
  toJSON(): string;
11
16
  }
@@ -1,4 +1,28 @@
1
+ import fs from "node:fs";
1
2
  import { FileProvider } from "./file-provider.js";
3
+ import { kIsClosed } from "./file-provider-internal.js";
4
+ /**
5
+ * One record per underlying file, shared by a deleteOnClose provider and every clone of it (including
6
+ * clones of clones). The file is unlinked when the last holder closes.
7
+ */
8
+ interface SharedFile {
9
+ path: string;
10
+ holders: number;
11
+ }
12
+ export interface Privates {
13
+ file: SharedFile;
14
+ size?: number;
15
+ deleteOnClose: boolean;
16
+ stream: fs.ReadStream | null;
17
+ closed: boolean;
18
+ }
19
+ type Unlink = (path: string, callback: (err: NodeJS.ErrnoException | null) => void) => void;
20
+ /**
21
+ * Releases a provider the runtime collected without close(): marks it closed, destroys any open stream and, for a
22
+ * deleteOnClose file, gives up this holder and unlinks the file when it was the last. Runs in a finalizer, so it
23
+ * never awaits and swallows every error; the unlink is fire-and-forget.
24
+ */
25
+ export declare function releaseUnclosed(p: Privates, unlink?: Unlink): void;
2
26
  export declare class FileProviderDisk implements FileProvider {
3
27
  constructor(path: string, deleteOnClose?: boolean);
4
28
  length(): Promise<number>;
@@ -8,5 +32,9 @@ export declare class FileProviderDisk implements FileProvider {
8
32
  blob(fileName?: string): Promise<Blob>;
9
33
  save(path?: string): Promise<string>;
10
34
  close(): Promise<void>;
35
+ clone(): FileProviderDisk;
36
+ equals(other: FileProvider): Promise<boolean>;
37
+ [kIsClosed](): boolean;
11
38
  toJSON(): string;
12
39
  }
40
+ export {};
@@ -0,0 +1,10 @@
1
+ import type { FileProvider } from "./file-provider.js";
2
+ /**
3
+ * Internal, non-interface hook shared by the platform FileProvider classes so that equals() can
4
+ * tell whether the other side is closed without calling it (a closed deleteOnClose disk provider
5
+ * fails with ENOENT rather than ResourceAlreadyClosed). Not exported from the package.
6
+ */
7
+ export declare const kIsClosed: unique symbol;
8
+ export declare function providerIsClosed(provider: FileProvider): boolean;
9
+ /** Compares two open providers byte for byte: length first, then both streams chunk by chunk. */
10
+ export declare function providerBytesEqual(a: FileProvider, b: FileProvider): Promise<boolean>;
@@ -0,0 +1,21 @@
1
+ import { FileProvider } from "./file-provider.js";
2
+ import { kIsClosed } from "./file-provider-internal.js";
3
+ /**
4
+ * What utilities.clone() puts in place of a FileProvider by default. It holds no file and no state: it serialises
5
+ * like a provider, closes as a no-op, and every read throws a ScriptError (FileProviderShallowClone) naming the
6
+ * option that makes a real clone. It counts as a FileProvider (isFileProvider() is true) so that a shim which reaches
7
+ * a connector fails on its first read rather than being uploaded as the text "[object FileProvider]".
8
+ */
9
+ export declare class FileProviderShim implements FileProvider {
10
+ length(): Promise<number>;
11
+ stream(_encoding?: "utf8" | "utf16le" | "ucs2" | "latin1"): Promise<NodeJS.ReadableStream>;
12
+ close(): Promise<void>;
13
+ save(_path?: string): Promise<string>;
14
+ formDataValue(_encoding?: "utf8" | "utf16le" | "ucs2" | "latin1"): Promise<unknown>;
15
+ blob(_fileName?: string): Promise<Blob>;
16
+ clone(): FileProvider;
17
+ equals(_other: FileProvider): Promise<boolean>;
18
+ /** a shim has no content, so a real provider's equals() treats it as closed and resolves false */
19
+ [kIsClosed](): boolean;
20
+ toJSON(): string;
21
+ }
@@ -1,5 +1,7 @@
1
1
  import { FileProvider } from "./file-provider.js";
2
+ import { kIsClosed } from "./file-provider-internal.js";
2
3
  export declare class FileProviderString implements FileProvider {
4
+ #private;
3
5
  constructor(source: string);
4
6
  length(): Promise<number>;
5
7
  stream(encoding?: "utf8" | "utf16le" | "ucs2" | "latin1"): Promise<NodeJS.ReadableStream>;
@@ -7,5 +9,8 @@ export declare class FileProviderString implements FileProvider {
7
9
  blob(fileName?: string): Promise<Blob>;
8
10
  save(path?: string): Promise<string>;
9
11
  close(): Promise<void>;
12
+ clone(): FileProviderString;
13
+ equals(other: FileProvider): Promise<boolean>;
14
+ [kIsClosed](): boolean;
10
15
  toJSON(): string;
11
16
  }
@@ -6,6 +6,24 @@ export interface FileProvider {
6
6
  formDataValue(encoding?: "utf8" | "utf16le" | "ucs2" | "latin1"): Promise<unknown>;
7
7
  blob(fileName?: string): Promise<Blob>;
8
8
  toJSON(): string;
9
+ /**
10
+ * Returns an independent provider over the same bytes. The clone must be closed like any other
11
+ * provider: closing the original does not close the clone, and closing the clone does not close
12
+ * the original. A clone of a string or buffer provider re-wraps the source (no disk is shared).
13
+ * A clone of a disk provider created with deleteOnClose shares the underlying file by reference
14
+ * count: the file is removed only when the original and every clone of it have been closed. A
15
+ * provider dropped without close is released when the runtime collects it, which may be much later.
16
+ * utilities.clone(value, { withFileProviders: true }) calls this for every provider in value; the
17
+ * default utilities.clone(value) does not, and puts a shallow-clone shim in its place.
18
+ * Throws ResourceAlreadyClosed when this provider has been closed.
19
+ */
20
+ clone(): FileProvider;
21
+ /**
22
+ * Resolves true when both providers hold the same bytes. Compares length() first, then streams both
23
+ * and compares chunk by chunk without buffering whole files. Resolves false, rather than throwing,
24
+ * when either side has been closed.
25
+ */
26
+ equals(other: FileProvider): Promise<boolean>;
9
27
  }
10
28
  export interface FileProviderOptions {
11
29
  file?: {
@@ -18,4 +36,10 @@ export interface FileProviderOptions {
18
36
  export declare function fileProvider(options: FileProviderOptions): FileProvider;
19
37
  export declare namespace fileProvider {
20
38
  var isFileProvider: (value: unknown) => value is FileProvider;
39
+ var closeAll: typeof closeFileProviders;
21
40
  }
41
+ /**
42
+ * Closes every FileProvider found in value, walking plain objects and arrays. Close errors are swallowed: this is
43
+ * for releasing providers the caller owns (for example the clones inside a snapshot) once it is done with them.
44
+ */
45
+ export declare function closeFileProviders(value: unknown): Promise<void>;
@@ -21,10 +21,24 @@ import * as TypeUtils from "./type-utils/type-utils.js";
21
21
  import * as JsonUtils from "./json-utils/index.js";
22
22
  import * as UpsertFlags from "./upsert-flags/upsert-flags.js";
23
23
  import { KvStore } from "@syncmatters/script-api";
24
+ export interface CloneOptions {
25
+ /**
26
+ * Clone each FileProvider with FileProvider.clone(), so the copy can read the file. Each cloned provider must be
27
+ * closed like any other. Without this, each provider in the copy is a shallow-clone shim.
28
+ */
29
+ withFileProviders?: boolean;
30
+ }
31
+ /**
32
+ * Deep copy. By default each FileProvider in value is replaced with a shallow-clone shim: it serialises like a
33
+ * provider and needs no close, but any read throws (ErrorCode.FileProviderShallowClone). That suits a snapshot for
34
+ * logging or tracing. Pass { withFileProviders: true } to clone the providers too (see FileProvider.clone()): the copy
35
+ * can read the files and each cloned provider must be closed.
36
+ */
37
+ declare function clone<T>(value: T, options?: CloneOptions): T;
24
38
  export declare const utilities: {
25
39
  arrayOfStr: typeof arrayOfStrings;
26
40
  arrayOfNum: typeof arrayOfNumbers;
27
- clone: <T>(value: T) => T;
41
+ clone: typeof clone;
28
42
  csvReader: (options: CsvReaderOptions) => import("./csv-reader/types.js").CsvReader;
29
43
  csvWriter: (options: CsvWriterOptions) => CsvWriter;
30
44
  dateFormat: typeof dateFormat;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@syncmatters/script-api",
3
- "version": "1.0.21",
3
+ "version": "1.0.23",
4
4
  "description": "TypeScript type definitions for the SyncMatters script API (types only - scripts execute on the SyncMatters platform)",
5
5
  "types": "./index.d.ts",
6
6
  "exports": {
@@ -28,7 +28,7 @@
28
28
  "license": "MIT",
29
29
  "author": "SyncMatters",
30
30
  "homepage": "https://syncmatters.com",
31
- "typesContentHash": "3dcf1af8c79b37f9fe75992f52dbbdc70d0f22515d9f65b1123ceb7e4f31de20",
31
+ "typesContentHash": "a7752b94739b0e0e5ab5d1a50afa574d6ca86c42013470349f048ab296bc4103",
32
32
  "dependencies": {
33
33
  "@types/node": "*"
34
34
  }
@@ -373,6 +373,13 @@ export interface UpsertOptions {
373
373
  meta: API.ObjectMeta;
374
374
  withIssueSimulators?: boolean;
375
375
  withUpsertFieldOptions?: API.JsonValuePath;
376
+ /**
377
+ * Path of a file field (`type: "file"`) on the object's upsert fields that the returned insert and update data
378
+ * should populate with a FileProvider. The harness sends it on the upsert and delete tests' prepare calls whenever
379
+ * the object has a file field, preferring one with `constraints.mandatory: "add"`; a hook that ignores it loses
380
+ * nothing. It is sent on every upsertPrepare call whenever the object has a file field, including the upsertClean
381
+ * test's (the one with `withIssueSimulators`).
382
+ */
376
383
  withFileField?: API.JsonValuePath;
377
384
  }
378
385
  /** Options passed when cleaning up after an upsert test. */
@@ -382,13 +389,25 @@ export interface UpsertCleanupOptions {
382
389
  }
383
390
  /** Data returned when requesting data that will be used for an upsert test. */
384
391
  export interface UpsertData {
385
- /** data to pass in the upsert call (ideally for an add operation) */
392
+ /**
393
+ * data to pass in the upsert call (ideally for an add operation). Return a fresh FileProvider in each call: the
394
+ * harness passes the connector a clone (`API.utilities.clone(insert, { withFileProviders: true })`, so each provider
395
+ * is a readable clone) and closes the originals and its clones after verifying.
396
+ */
386
397
  insert?: unknown;
387
- /** a simple update that may be made to the added row (the test harness will copy over the key from the add response) */
398
+ /**
399
+ * a simple update that may be made to the added row (the test harness will copy over the key from the add response).
400
+ * FileProviders here follow the same rule as `insert`: fresh per call, cloned before writing, closed after verifying.
401
+ */
388
402
  update?: unknown;
389
403
  /** if requested, include 'bad data' examples that will trigger upsertClean issues */
390
404
  issueSimulators?: Array<UpsertIssueSimulator>;
391
- /** fields to check to verify the insert/update was successful */
405
+ /**
406
+ * fields to check to verify the insert/update was successful. The same paths are checked on the update row, so a
407
+ * listed field must be set in both `insert` and `update`. A file field compares by bytes through
408
+ * `FileProvider.equals()`: the queried value must be a FileProvider holding the same content. Listing a file field
409
+ * here (or in `fields`) makes the query-back download its content; the harness never adds it on its own.
410
+ */
392
411
  verifyFields?: Array<API.JsonValuePath>;
393
412
  /** optionally specify other fields to include when querying the data back for validation */
394
413
  fields?: Array<API.JsonValuePath>;