@granular-software/sdk 0.4.19 → 0.4.21

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 CHANGED
@@ -353,6 +353,14 @@ After applying the manifest and publishing tools, the sandbox gets **auto-genera
353
353
 
354
354
  ```typescript
355
355
  // What the sandbox sees (auto-generated):
356
+ export interface SandboxPageResult<T> {
357
+ items: T[];
358
+ page: number;
359
+ perPage: number;
360
+ totalCount: number;
361
+ hasMore: boolean;
362
+ }
363
+
356
364
  export declare class Author {
357
365
  readonly id: string;
358
366
  readonly name: string;
@@ -363,8 +371,17 @@ export declare class Author {
363
371
  /** Get a cached Author by graph path, hydrating from the graph when needed */
364
372
  static get(query: { path: string; refresh?: boolean }): Promise<Author | null>;
365
373
 
366
- /** List known Author instances */
367
- static list(query?: { limit?: number; saveAs?: string; refresh?: boolean }): Promise<Author[]>;
374
+ /** Count Author instances without loading them into the heap */
375
+ static count(): Promise<number>;
376
+
377
+ /** Return one page of Author instances together with pagination metadata */
378
+ static page(query?: { page?: number; perPage?: number; limit?: number; saveAs?: string; refresh?: boolean }): Promise<SandboxPageResult<Author>>;
379
+
380
+ /** List one page of Author instances */
381
+ static list(query?: { page?: number; perPage?: number; limit?: number; saveAs?: string; refresh?: boolean }): Promise<Author[]>;
382
+
383
+ /** Stream Author instances page by page */
384
+ static iterate(query?: { page?: number; perPage?: number; limit?: number; maxItems?: number; refresh?: boolean }): AsyncIterable<Author>;
368
385
 
369
386
  /** Get biography of an author */
370
387
  get_bio(input?: { detailed?: boolean }): Promise<{ bio: string; source?: string }>;
@@ -384,7 +401,13 @@ export declare class Book {
384
401
 
385
402
  static get(query: { path: string; refresh?: boolean }): Promise<Book | null>;
386
403
 
387
- static list(query?: { limit?: number; saveAs?: string; refresh?: boolean }): Promise<Book[]>;
404
+ static count(): Promise<number>;
405
+
406
+ static page(query?: { page?: number; perPage?: number; limit?: number; saveAs?: string; refresh?: boolean }): Promise<SandboxPageResult<Book>>;
407
+
408
+ static list(query?: { page?: number; perPage?: number; limit?: number; saveAs?: string; refresh?: boolean }): Promise<Book[]>;
409
+
410
+ static iterate(query?: { page?: number; perPage?: number; limit?: number; maxItems?: number; refresh?: boolean }): AsyncIterable<Book>;
388
411
 
389
412
  /** Navigate to author (many_to_one) */
390
413
  get_author(): Promise<Author | null>;
@@ -399,11 +422,18 @@ The LLM or user writes code against these typed classes:
399
422
  ```typescript
400
423
  import { Author, Book, global_search } from './sandbox-tools';
401
424
 
402
- const authors = await Author.list();
425
+ const totalAuthors = await Author.count();
426
+ const firstPage = await Author.page({ page: 1, perPage: 25 });
427
+ const authors = firstPage.items;
403
428
  const tolkien = authors.find((author) => author.name === 'J.R.R. Tolkien');
404
429
  if (!tolkien) throw new Error('Author not found');
430
+ console.log(totalAuthors, firstPage.hasMore);
405
431
  console.log(tolkien.name); // "J.R.R. Tolkien"
406
432
 
433
+ for await (const author of Author.iterate({ perPage: 100, maxItems: 200 })) {
434
+ console.log(author.id);
435
+ }
436
+
407
437
  const bio = await tolkien.get_bio({ detailed: true });
408
438
  console.log(bio.bio); // typed as string
409
439
 
@@ -456,6 +486,16 @@ Defines domain ontology: classes (with typed properties), and relationships (wit
456
486
  ### `environment.recordObject(options)`
457
487
  Creates or updates a class instance with fields and relationships. Returns `{ path, id, created }`.
458
488
 
489
+ ### `environment.recordObjects(records, options?)`
490
+ Batch upsert for many instances. The SDK sends **chunks** (default **100** rows per HTTP `POST` to `/records/batch`) with **retries** on transient failures, so large arrays do not time out as a single oversized request.
491
+
492
+ Optional **`options`**:
493
+ - **`batchSize`** — max rows per request (default 100).
494
+ - **`concurrency`** — how many chunk requests may run in parallel (default 1, max 16); can reduce wall time when the server can overlap work.
495
+ - **`onChunkComplete`** — async-friendly hook after each chunk for progress UIs; the returned array is always ordered like `records`.
496
+
497
+ **Sync batch vs queued import:** use **`recordObjects`** when you need **synchronous** commits and/or per-chunk feedback. Use **`enqueueRecordImport`** + **`getRecordImport` / `getRecordImportSummary`** for **background** ingestion with aggregate counters when admission latency matters more than immediate row-by-row completion.
498
+
459
499
  ### `environment.getRelationships(modelPath)`
460
500
  Returns relationship definitions for a given class.
461
501
 
@@ -1,4 +1,4 @@
1
- import { P as Prompt, c as Environment, b as SessionHeapSnapshot, aZ as ManifestContent, ax as RecordObjectOptions, T as ToolWithHandler, G as Granular, C as ConnectOptions, y as CreateEnvironmentData, i as GranularOptions } from './client-BQw_gUK3.mjs';
1
+ import { P as Prompt, d as Environment, c as SessionHeapSnapshot, a$ as ManifestContent, ax as RecordObjectOptions, T as ToolWithHandler, G as Granular, C as ConnectOptions, y as CreateEnvironmentData, i as GranularOptions } from './client-DLGC0mJk.mjs';
2
2
  import { GeneratedJobCodeIssue, HarnessControllerBudgets } from './agent-harness.mjs';
3
3
  import '@automerge/automerge';
4
4
  import '@automerge/automerge/slim';
@@ -1,4 +1,4 @@
1
- import { P as Prompt, c as Environment, b as SessionHeapSnapshot, aZ as ManifestContent, ax as RecordObjectOptions, T as ToolWithHandler, G as Granular, C as ConnectOptions, y as CreateEnvironmentData, i as GranularOptions } from './client-BQw_gUK3.js';
1
+ import { P as Prompt, d as Environment, c as SessionHeapSnapshot, a$ as ManifestContent, ax as RecordObjectOptions, T as ToolWithHandler, G as Granular, C as ConnectOptions, y as CreateEnvironmentData, i as GranularOptions } from './client-DLGC0mJk.js';
2
2
  import { GeneratedJobCodeIssue, HarnessControllerBudgets } from './agent-harness.js';
3
3
  import '@automerge/automerge';
4
4
  import '@automerge/automerge/slim';
@@ -4687,10 +4687,15 @@ var Session = class {
4687
4687
  * ```typescript
4688
4688
  * import { Author, Book, global_search } from './sandbox-tools';
4689
4689
  *
4690
- * const authors = await Author.list({ limit: 10, saveAs: 'recent_authors' });
4690
+ * const totalAuthors = await Author.count();
4691
+ * const firstAuthorsPage = await Author.page({ page: 1, perPage: 10, saveAs: 'recent_authors' });
4692
+ * const authors = firstAuthorsPage.items;
4691
4693
  * const tolkien = await Author.get({ path: 'author_tolkien' });
4692
4694
  * const bio = await tolkien.get_bio({ detailed: true });
4693
4695
  * const books = await tolkien.get_books();
4696
+ * for await (const author of Author.iterate({ perPage: 100, maxItems: 500 })) {
4697
+ * console.log(author.id);
4698
+ * }
4694
4699
  * ```
4695
4700
  *
4696
4701
  * Effect calls (instance methods, static methods, global functions) trigger
@@ -11248,14 +11253,49 @@ var STANDARD_MODULES_OPERATIONS = [
11248
11253
  { create: "class", extends: "entity", has: {} },
11249
11254
  { create: "user", extends: "entity", has: { email: { value: void 0 }, firstName: { value: void 0 }, lastName: { value: void 0 } } },
11250
11255
  { create: "company", extends: "entity", has: { name: { value: void 0 }, website: { value: void 0 } } },
11251
- { create: "string", has: { value: { value: void 0 } } },
11252
- { create: "number", has: { value: { value: 0 } } },
11253
- { create: "boolean", has: { value: { value: false } } },
11256
+ { create: "string", has: {} },
11257
+ { create: "number", has: {} },
11258
+ { create: "boolean", has: {} },
11254
11259
  { create: "tool_parameter", has: { name: { value: void 0 }, type: { value: "string" }, description: { value: void 0 }, required: { value: false } } }
11255
11260
  ];
11256
11261
  var BUILTIN_MODULES = {
11257
11262
  "standard_modules": STANDARD_MODULES_OPERATIONS
11258
11263
  };
11264
+ var DEFAULT_DIRECT_RECORD_OBJECTS_REQUEST_BATCH_SIZE = 100;
11265
+ var MAX_RECORD_OBJECTS_CONCURRENCY = 16;
11266
+ var DEFAULT_DIRECT_RECORD_OBJECTS_RETRY_COUNT = 3;
11267
+ var DEFAULT_DIRECT_RECORD_OBJECTS_RETRY_DELAY_MS = 1e3;
11268
+ var LOCAL_CONTROL_REQUEST_RETRY_COUNT = 4;
11269
+ var LOCAL_CONTROL_REQUEST_RETRY_DELAY_MS = 500;
11270
+ function planRecordObjectsChunks(records, batchSize) {
11271
+ const total = records.length;
11272
+ const size = Math.max(1, Math.min(batchSize, total));
11273
+ const chunkCount = Math.ceil(total / size);
11274
+ const plans = [];
11275
+ for (let offset = 0, chunkIndex = 0; offset < total; offset += size, chunkIndex += 1) {
11276
+ const slice = records.slice(offset, offset + size);
11277
+ plans.push({ chunkIndex, chunkCount, offset, slice });
11278
+ }
11279
+ return plans;
11280
+ }
11281
+ function sleep(ms) {
11282
+ return new Promise((resolve) => setTimeout(resolve, ms));
11283
+ }
11284
+ function isLocalControlUrl(url) {
11285
+ try {
11286
+ const parsed = new URL(url);
11287
+ return parsed.hostname === "127.0.0.1" || parsed.hostname === "localhost" || parsed.hostname === "::1";
11288
+ } catch {
11289
+ return false;
11290
+ }
11291
+ }
11292
+ function isRetryableLocalWorkerRestart(status, body, url) {
11293
+ return isLocalControlUrl(url) && (status === 503 && body.includes("Your worker restarted mid-request") || status === 500 && body.includes("Network connection lost"));
11294
+ }
11295
+ function isRetryableRecordObjectsError(error) {
11296
+ const message = error instanceof Error ? error.message : String(error);
11297
+ return /worker restarted mid-request|econnreset|network|socket connection was closed unexpectedly|timed out/i.test(message);
11298
+ }
11259
11299
  function computeEffectKey2(effect) {
11260
11300
  const attachedClass = effect.className?.trim();
11261
11301
  if (!attachedClass) {
@@ -12213,24 +12253,101 @@ var Environment = class extends Session {
12213
12253
  /**
12214
12254
  * Batch version of `recordObject()`.
12215
12255
  *
12216
- * Sends several upserts through the control-plane batch endpoint so the
12217
- * server can collapse the graph mutations into far fewer round trips.
12256
+ * Sends rows through the control-plane **`/records/batch`** endpoint in **chunks** (default
12257
+ * **100** records per HTTP request) so individual requests stay bounded and gateway timeouts are
12258
+ * unlikely. Each chunk is retried on transient network / worker errors.
12259
+ *
12260
+ * Use the optional second argument to:
12261
+ * - **`batchSize`** — rows per POST (smaller = more progress events; larger = fewer round trips).
12262
+ * - **`concurrency`** — run up to N chunk POSTs in parallel (capped at 16) when you want lower wall time.
12263
+ * - **`onChunkComplete`** — hook for UIs after each chunk succeeds (row order in the returned array
12264
+ * always matches `records`; chunk **completion** order may differ when `concurrency > 1`).
12265
+ *
12266
+ * For **asynchronous** ingestion with worker-side batching and aggregate counters (`queued`,
12267
+ * `completed`, …), use **`enqueueRecordImport`** and poll **`getRecordImport`** /
12268
+ * **`getRecordImportSummary`** — best for very large fire-and-forget loads when immediate
12269
+ * synchronous commit of every row is not required.
12218
12270
  */
12219
- async recordObjects(records) {
12271
+ async recordObjects(records, options) {
12220
12272
  if (!Array.isArray(records) || records.length === 0) {
12221
12273
  return [];
12222
12274
  }
12223
- const response = await this.controlPlaneRequest(
12224
- `/control/environments/${this.environmentId}/records/batch`,
12225
- {
12226
- method: "POST",
12227
- body: JSON.stringify({ records })
12228
- }
12275
+ const batchSize = Math.max(
12276
+ 1,
12277
+ Math.min(
12278
+ records.length,
12279
+ options?.batchSize ?? DEFAULT_DIRECT_RECORD_OBJECTS_REQUEST_BATCH_SIZE
12280
+ )
12229
12281
  );
12230
- return Array.isArray(response.items) ? response.items : [];
12282
+ const concurrency = Math.min(
12283
+ MAX_RECORD_OBJECTS_CONCURRENCY,
12284
+ Math.max(1, options?.concurrency ?? 1)
12285
+ );
12286
+ const plans = planRecordObjectsChunks(records, batchSize);
12287
+ const total = records.length;
12288
+ const results = new Array(total);
12289
+ const onChunk = options?.onChunkComplete;
12290
+ for (let waveStart = 0; waveStart < plans.length; waveStart += concurrency) {
12291
+ const wave = plans.slice(waveStart, waveStart + concurrency);
12292
+ await Promise.all(
12293
+ wave.map(async (plan) => {
12294
+ const { items, durationMs } = await this.executeRecordObjectsChunk(plan.slice);
12295
+ if (items.length !== plan.slice.length) {
12296
+ throw new Error(
12297
+ `recordObjects: chunk ${plan.chunkIndex + 1} returned ${items.length} results, expected ${plan.slice.length}`
12298
+ );
12299
+ }
12300
+ for (let index = 0; index < items.length; index += 1) {
12301
+ results[plan.offset + index] = items[index];
12302
+ }
12303
+ if (onChunk) {
12304
+ const info = {
12305
+ chunkIndex: plan.chunkIndex,
12306
+ totalChunks: plan.chunkCount,
12307
+ offset: plan.offset,
12308
+ recordCount: plan.slice.length,
12309
+ durationMs,
12310
+ results: items
12311
+ };
12312
+ await onChunk(info);
12313
+ }
12314
+ })
12315
+ );
12316
+ }
12317
+ return results;
12318
+ }
12319
+ async executeRecordObjectsChunk(chunk) {
12320
+ const wallStart = Date.now();
12321
+ let lastError;
12322
+ for (let attempt = 1; attempt <= DEFAULT_DIRECT_RECORD_OBJECTS_RETRY_COUNT; attempt += 1) {
12323
+ try {
12324
+ const response = await this.controlPlaneRequest(
12325
+ `/control/environments/${this.environmentId}/records/batch`,
12326
+ {
12327
+ method: "POST",
12328
+ body: JSON.stringify({ records: chunk })
12329
+ }
12330
+ );
12331
+ const items = Array.isArray(response.items) ? response.items : [];
12332
+ return { items, durationMs: Date.now() - wallStart };
12333
+ } catch (error) {
12334
+ lastError = error;
12335
+ if (!isRetryableRecordObjectsError(error) || attempt === DEFAULT_DIRECT_RECORD_OBJECTS_RETRY_COUNT) {
12336
+ throw error;
12337
+ }
12338
+ await sleep(DEFAULT_DIRECT_RECORD_OBJECTS_RETRY_DELAY_MS * attempt);
12339
+ }
12340
+ }
12341
+ throw lastError instanceof Error ? lastError : new Error(String(lastError));
12231
12342
  }
12232
12343
  /**
12233
- * Queue a background record import for this environment.
12344
+ * Queue a background record import for this environment (async worker pipeline).
12345
+ *
12346
+ * **vs `recordObjects`:** this path accepts the full payload in one request, returns an
12347
+ * **`importId`**, and processes rows in the background — use **`getRecordImport`** /
12348
+ * **`getRecordImportSummary`** for progress. Choose it for large bulk loads where you do not
12349
+ * need every row committed before the HTTP call returns. Use **`recordObjects`** when you need
12350
+ * synchronous upserts and fine-grained chunk progress via **`onChunkComplete`**.
12234
12351
  */
12235
12352
  async enqueueRecordImport(records, options = {}) {
12236
12353
  return this.controlPlaneRequest(
@@ -13198,23 +13315,36 @@ var Granular = class _Granular {
13198
13315
  if (this.debugHttp) {
13199
13316
  console.log(`[SDK] Requesting: ${url}`);
13200
13317
  }
13201
- const response = await fetch(url, {
13202
- ...options,
13203
- headers: {
13204
- "Authorization": `Bearer ${this.apiKey}`,
13205
- "Content-Type": "application/json",
13206
- "Connection": "close",
13207
- ...options.headers
13318
+ for (let attempt = 1; attempt <= LOCAL_CONTROL_REQUEST_RETRY_COUNT; attempt += 1) {
13319
+ const response = await fetch(url, {
13320
+ ...options,
13321
+ headers: {
13322
+ "Authorization": `Bearer ${this.apiKey}`,
13323
+ "Content-Type": "application/json",
13324
+ "Connection": "close",
13325
+ ...options.headers
13326
+ }
13327
+ });
13328
+ if (response.ok) {
13329
+ if (response.status === 204) {
13330
+ return { deleted: true };
13331
+ }
13332
+ return response.json();
13208
13333
  }
13209
- });
13210
- if (!response.ok) {
13211
13334
  const errorText = await response.text();
13335
+ const retryable = isRetryableLocalWorkerRestart(response.status, errorText, url);
13336
+ if (retryable && attempt < LOCAL_CONTROL_REQUEST_RETRY_COUNT) {
13337
+ if (this.debugHttp) {
13338
+ console.warn(
13339
+ `[SDK] Retrying local control request after worker restart (${attempt}/${LOCAL_CONTROL_REQUEST_RETRY_COUNT - 1} retries used): ${url}`
13340
+ );
13341
+ }
13342
+ await sleep(LOCAL_CONTROL_REQUEST_RETRY_DELAY_MS * attempt);
13343
+ continue;
13344
+ }
13212
13345
  throw new Error(`Granular API Error (${response.status}): ${errorText}`);
13213
13346
  }
13214
- if (response.status === 204) {
13215
- return { deleted: true };
13216
- }
13217
- return response.json();
13347
+ throw new Error(`Granular API Error: exhausted retries for ${url}`);
13218
13348
  }
13219
13349
  };
13220
13350
 
@@ -14135,7 +14265,10 @@ ${loopBlock}
14135
14265
  - Only use classes, methods, and parameter shapes that are explicitly declared in those typedefs.
14136
14266
  - Never invent helper methods such as \`find(...)\` or unsupported parameters such as \`id\` when the typedefs require \`path\`.
14137
14267
  - Use \`ClassName.get({ path })\` only when you already know an object's graph path.
14138
- - Use \`ClassName.list({ limit, saveAs })\` to load typed lists and persist reusable named lists in the heap.
14268
+ - Use \`ClassName.count()\` when you only need a total.
14269
+ - Use \`ClassName.page({ page, perPage, saveAs })\` when you need both records and pagination metadata like \`totalCount\` or \`hasMore\`.
14270
+ - Use \`ClassName.list({ page, perPage, saveAs })\` to load one typed page of records. \`limit\` is only a legacy alias for \`perPage\`.
14271
+ - Use \`for await (const item of ClassName.iterate({ perPage, maxItems }))\` for large batch jobs so you do not materialize the whole result set at once.
14139
14272
  - Instance methods: \`await instance.method_name(params)\`.
14140
14273
  - Static methods: \`await ClassName.static_method(params)\`.
14141
14274
  - Global effects: \`await effect_name(params)\`.
@@ -14147,7 +14280,7 @@ ${loopBlock}
14147
14280
  - Status fields are free-form operational strings, not strict enums. Normalize spelling mentally and do not rely on brittle hard-coded sets that miss variants like \`in-progress\`, \`in_progress\`, \`awaiting-part\`, or \`approval-submitted\`.
14148
14281
  - Do not discard a case, work order, part request, or shipment only because its status string does not match your preferred "open" spelling. If the record is otherwise the clear match, inspect it.
14149
14282
  - Reuse \`heap.getVar(name)\`, \`heap.setVar(name, value)\`, and \`heap.deleteVar(name)\` only when it clearly helps the next step. Do not mirror data into the heap just for completeness.
14150
- - Prefer \`heap.setVar(name, value)\` for scalars or one selected instance. Prefer \`ClassName.list({ saveAs })\` for lists instead of \`heap.setVar(name, array)\`.
14283
+ - Prefer \`heap.setVar(name, value)\` for scalars or one selected instance. Prefer \`ClassName.list({ page, perPage, saveAs })\` for reusable list pages instead of \`heap.setVar(name, array)\`.
14151
14284
  - Never write an empty array into the heap. If a filtered list is empty, keep it local or clear the previous heap value with \`heap.deleteVar(name)\`.
14152
14285
  - Prefer heap-backed state that represents the current choice or recommendation. Avoid storing extra scalar bookkeeping unless it is needed for the next concrete step.
14153
14286
  - Only store true sandbox instances, typed lists of sandbox instances, or scalars in the heap. Results returned by static effects like availability/search helpers are often plain JSON, not sandbox instances.
@@ -14589,7 +14722,7 @@ ${modelOutputInstruction()}` },
14589
14722
  if (!response.ok) {
14590
14723
  const errorText = await response.text();
14591
14724
  if (attempt < 3 && (response.status >= 500 || response.status === 429)) {
14592
- await sleep(500 * attempt);
14725
+ await sleep2(500 * attempt);
14593
14726
  continue;
14594
14727
  }
14595
14728
  throw new Error(`OpenAI chat generation failed: ${response.status} ${errorText}`);
@@ -14600,7 +14733,7 @@ ${modelOutputInstruction()}` },
14600
14733
  const parsed = extractJsonObject(text);
14601
14734
  if (!parsed) {
14602
14735
  if (attempt < 3) {
14603
- await sleep(300 * attempt);
14736
+ await sleep2(300 * attempt);
14604
14737
  continue;
14605
14738
  }
14606
14739
  throw new Error(`Model output was not valid JSON:
@@ -14614,7 +14747,7 @@ ${text}`);
14614
14747
  } catch (error) {
14615
14748
  lastError = error instanceof Error ? error : new Error(String(error));
14616
14749
  if (attempt < 3 && /socket connection was closed unexpectedly|ECONNRESET|network|timed out/i.test(lastError.message)) {
14617
- await sleep(500 * attempt);
14750
+ await sleep2(500 * attempt);
14618
14751
  continue;
14619
14752
  }
14620
14753
  throw lastError;
@@ -14626,7 +14759,7 @@ ${text}`);
14626
14759
  async function ensureDir(dir) {
14627
14760
  await promises.mkdir(dir, { recursive: true });
14628
14761
  }
14629
- async function sleep(ms) {
14762
+ async function sleep2(ms) {
14630
14763
  await new Promise((resolve) => setTimeout(resolve, ms));
14631
14764
  }
14632
14765
  async function withTimeout(promise, ms, label) {
@@ -15069,7 +15202,7 @@ function createAgentEvalHarness(options) {
15069
15202
  stderr: [...pending.stderr, ...resumed.stderr]
15070
15203
  };
15071
15204
  }
15072
- await sleep(350);
15205
+ await sleep2(350);
15073
15206
  const liveDoc = cloneJson(pending.conversation.environment.document);
15074
15207
  const presentation = resolveJobPresentation({
15075
15208
  jobId: pending.job.id,
@@ -15238,7 +15371,7 @@ function createAgentEvalHarness(options) {
15238
15371
  if (outcome.kind !== "completed") {
15239
15372
  throw new Error("Unexpected non-completed outcome after prompt handling");
15240
15373
  }
15241
- await sleep(350);
15374
+ await sleep2(350);
15242
15375
  const settledLiveDoc = cloneJson(conversation.environment.document);
15243
15376
  const sessionHeap = normalizeHeapSnapshot2(asRecord4(settledLiveDoc?.heap));
15244
15377
  const presentation = resolveJobPresentation({