@withone/cli 1.37.2 → 1.38.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.
package/README.md CHANGED
@@ -343,7 +343,7 @@ one sync run stripe --full-refresh
343
343
  | `schedule add/list/status/remove/repair` | Cron-backed scheduled syncs with drift detection |
344
344
  | `remove <platform>` | Delete local data (`--dry-run` to preview) |
345
345
 
346
- Change hooks (`onInsert`, `onUpdate`, `onChange`) fire per-page during sync — pipe to a shell command, a flow, or an event log. Run `one guide sync` for the full reference.
346
+ Change hooks (`onInsert`, `onUpdate`, `onChange`) fire per-page during sync — pipe to a shell command, a flow, or an event log. Root-array responses (e.g. Hacker News `/v0/topstories.json` → `[9129911, 9129199, ...]`) are supported by setting `resultsPath` to `""`, `"$"`, or `"."`; primitive elements are auto-wrapped as `{ [idField]: value }`. Run `one guide sync` for the full reference.
347
347
 
348
348
  ### `one guide [topic]`
349
349
 
package/dist/index.js CHANGED
@@ -4727,12 +4727,15 @@ function readProfile(platform, model) {
4727
4727
  }
4728
4728
  }
4729
4729
  function writeProfile(profile) {
4730
- const required = ["platform", "model", "connectionKey", "actionId", "resultsPath", "idField", "pagination"];
4730
+ const required = ["platform", "model", "connectionKey", "actionId", "idField", "pagination"];
4731
4731
  for (const field of required) {
4732
4732
  if (!profile[field]) {
4733
4733
  throw new Error(`Missing required field: ${field}`);
4734
4734
  }
4735
4735
  }
4736
+ if (profile.resultsPath === void 0) {
4737
+ throw new Error('Missing required field: resultsPath (use "" or "$" for root-array responses)');
4738
+ }
4736
4739
  if (!profile.pagination.type) {
4737
4740
  throw new Error("Missing required field: pagination.type");
4738
4741
  }
@@ -4853,30 +4856,82 @@ function handleId(response, config2, records) {
4853
4856
  import fs9 from "fs";
4854
4857
  import path9 from "path";
4855
4858
  var SYNC_DIR = path9.join(".one", "sync");
4856
- var STATE_FILE = path9.join(SYNC_DIR, "sync_state.json");
4857
- function readSyncState() {
4859
+ var STATE_DIR = path9.join(SYNC_DIR, "state");
4860
+ var LEGACY_STATE_FILE = path9.join(SYNC_DIR, "sync_state.json");
4861
+ function modelFilePath(platform, model) {
4862
+ return path9.join(STATE_DIR, platform, `${model}.json`);
4863
+ }
4864
+ function migrateLegacyIfNeeded() {
4865
+ if (!fs9.existsSync(LEGACY_STATE_FILE)) return;
4858
4866
  try {
4859
- if (!fs9.existsSync(STATE_FILE)) return {};
4860
- const raw = fs9.readFileSync(STATE_FILE, "utf-8");
4861
- return JSON.parse(raw);
4867
+ const raw = fs9.readFileSync(LEGACY_STATE_FILE, "utf-8");
4868
+ const legacy = JSON.parse(raw);
4869
+ for (const [platform, models] of Object.entries(legacy)) {
4870
+ for (const [model, modelState] of Object.entries(models)) {
4871
+ if (fs9.existsSync(modelFilePath(platform, model))) continue;
4872
+ writeModelFile(platform, model, modelState);
4873
+ }
4874
+ }
4875
+ fs9.unlinkSync(LEGACY_STATE_FILE);
4862
4876
  } catch {
4863
- return {};
4877
+ try {
4878
+ fs9.unlinkSync(LEGACY_STATE_FILE);
4879
+ } catch {
4880
+ }
4864
4881
  }
4865
4882
  }
4866
- function writeSyncState(state) {
4867
- fs9.mkdirSync(SYNC_DIR, { recursive: true });
4868
- const tmp = STATE_FILE + ".tmp";
4883
+ function writeModelFile(platform, model, state) {
4884
+ const dir = path9.join(STATE_DIR, platform);
4885
+ fs9.mkdirSync(dir, { recursive: true });
4886
+ const file = path9.join(dir, `${model}.json`);
4887
+ const tmp = `${file}.${process.pid}.${Math.random().toString(36).slice(2, 10)}.tmp`;
4869
4888
  fs9.writeFileSync(tmp, JSON.stringify(state, null, 2));
4870
- fs9.renameSync(tmp, STATE_FILE);
4889
+ fs9.renameSync(tmp, file);
4890
+ }
4891
+ function readModelFile(platform, model) {
4892
+ try {
4893
+ const raw = fs9.readFileSync(modelFilePath(platform, model), "utf-8");
4894
+ return JSON.parse(raw);
4895
+ } catch {
4896
+ return null;
4897
+ }
4898
+ }
4899
+ function readSyncState() {
4900
+ migrateLegacyIfNeeded();
4901
+ const result = {};
4902
+ let platforms;
4903
+ try {
4904
+ platforms = fs9.readdirSync(STATE_DIR);
4905
+ } catch {
4906
+ return result;
4907
+ }
4908
+ for (const platform of platforms) {
4909
+ const platformDir = path9.join(STATE_DIR, platform);
4910
+ let entries;
4911
+ try {
4912
+ entries = fs9.readdirSync(platformDir);
4913
+ } catch {
4914
+ continue;
4915
+ }
4916
+ for (const entry of entries) {
4917
+ if (!entry.endsWith(".json")) continue;
4918
+ const model = entry.slice(0, -".json".length);
4919
+ const modelState = readModelFile(platform, model);
4920
+ if (modelState) {
4921
+ if (!result[platform]) result[platform] = {};
4922
+ result[platform][model] = modelState;
4923
+ }
4924
+ }
4925
+ }
4926
+ return result;
4871
4927
  }
4872
4928
  function getModelState(platform, model) {
4873
- const state = readSyncState();
4874
- return state[platform]?.[model] ?? null;
4929
+ migrateLegacyIfNeeded();
4930
+ return readModelFile(platform, model);
4875
4931
  }
4876
4932
  function updateModelState(platform, model, partial) {
4877
- const state = readSyncState();
4878
- if (!state[platform]) state[platform] = {};
4879
- const existing = state[platform][model] ?? {
4933
+ migrateLegacyIfNeeded();
4934
+ const existing = readModelFile(platform, model) ?? {
4880
4935
  lastSync: null,
4881
4936
  lastCursor: null,
4882
4937
  totalRecords: 0,
@@ -4884,21 +4939,26 @@ function updateModelState(platform, model, partial) {
4884
4939
  since: null,
4885
4940
  status: "idle"
4886
4941
  };
4887
- state[platform][model] = { ...existing, ...partial };
4888
- writeSyncState(state);
4942
+ writeModelFile(platform, model, { ...existing, ...partial });
4889
4943
  }
4890
4944
  function removeModelState(platform, model) {
4891
- const state = readSyncState();
4892
- if (!state[platform]) return;
4945
+ migrateLegacyIfNeeded();
4946
+ const platformDir = path9.join(STATE_DIR, platform);
4893
4947
  if (model) {
4894
- delete state[platform][model];
4895
- if (Object.keys(state[platform]).length === 0) {
4896
- delete state[platform];
4948
+ try {
4949
+ fs9.unlinkSync(modelFilePath(platform, model));
4950
+ } catch {
4951
+ }
4952
+ try {
4953
+ if (fs9.readdirSync(platformDir).length === 0) fs9.rmdirSync(platformDir);
4954
+ } catch {
4897
4955
  }
4898
4956
  } else {
4899
- delete state[platform];
4957
+ try {
4958
+ fs9.rmSync(platformDir, { recursive: true, force: true });
4959
+ } catch {
4960
+ }
4900
4961
  }
4901
- writeSyncState(state);
4902
4962
  }
4903
4963
 
4904
4964
  // src/lib/sync/db.ts
@@ -5637,6 +5697,50 @@ function detectColumnType2(value) {
5637
5697
  return "TEXT";
5638
5698
  }
5639
5699
 
5700
+ // src/lib/sync/extract.ts
5701
+ var ROOT_PATH_TOKENS = /* @__PURE__ */ new Set(["", "$", "."]);
5702
+ function isRootPath(resultsPath) {
5703
+ return resultsPath === void 0 || resultsPath === null || ROOT_PATH_TOKENS.has(resultsPath);
5704
+ }
5705
+ function isPrimitive(value) {
5706
+ const t = typeof value;
5707
+ return t === "string" || t === "number" || t === "boolean";
5708
+ }
5709
+ function describeType(value) {
5710
+ if (value === null) return "null";
5711
+ if (Array.isArray(value)) return "array";
5712
+ return typeof value;
5713
+ }
5714
+ function extractRecords(responseData, resultsPath, idField, profileLabel) {
5715
+ let candidate;
5716
+ if (isRootPath(resultsPath)) {
5717
+ candidate = responseData;
5718
+ } else {
5719
+ candidate = getByDotPath(responseData, resultsPath);
5720
+ }
5721
+ if (!Array.isArray(candidate)) {
5722
+ const topKeys = typeof responseData === "object" && responseData !== null && !Array.isArray(responseData) ? Object.keys(responseData).slice(0, 10) : [];
5723
+ const pathLabel = isRootPath(resultsPath) ? "<root>" : `'${resultsPath}'`;
5724
+ const typeLabel = describeType(responseData);
5725
+ const keyHint = topKeys.length > 0 ? ` Top-level keys: [${topKeys.join(", ")}].` : "";
5726
+ throw new Error(
5727
+ `Could not find results at path ${pathLabel} for profile ${profileLabel}. Response top-level type is ${typeLabel}.${keyHint} Set resultsPath to the array field, or use "" / "$" / "." for root arrays.`
5728
+ );
5729
+ }
5730
+ if (candidate.length === 0) {
5731
+ return { records: [], wrappedPrimitives: false };
5732
+ }
5733
+ if (isPrimitive(candidate[0])) {
5734
+ const wrapped = [];
5735
+ for (const value of candidate) {
5736
+ if (!isPrimitive(value)) continue;
5737
+ wrapped.push({ [idField]: String(value) });
5738
+ }
5739
+ return { records: wrapped, wrappedPrimitives: true };
5740
+ }
5741
+ return { records: candidate, wrappedPrimitives: false };
5742
+ }
5743
+
5640
5744
  // src/lib/sync/runner.ts
5641
5745
  var MAX_RETRIES_PER_PAGE = 3;
5642
5746
  var DEFAULT_SINCE_DAYS = 90;
@@ -5881,13 +5985,13 @@ async function syncModel(api, profile, options) {
5881
5985
  throw err;
5882
5986
  }
5883
5987
  }
5884
- const records = getByDotPath(responseData, profile.resultsPath);
5885
- if (!Array.isArray(records)) {
5886
- const topKeys = typeof responseData === "object" && responseData !== null ? Object.keys(responseData) : [];
5887
- throw new Error(
5888
- `Could not find results at path '${profile.resultsPath}' in API response. Check your sync profile. Response keys: [${topKeys.join(", ")}]`
5889
- );
5890
- }
5988
+ const extraction = extractRecords(
5989
+ responseData,
5990
+ profile.resultsPath,
5991
+ profile.idField,
5992
+ `${platform}/${model}`
5993
+ );
5994
+ const records = extraction.records;
5891
5995
  if (records.length === 0 && page === 0) {
5892
5996
  if (!isAgentMode()) {
5893
5997
  process.stderr.write(`No records found for ${model} with the given filters.
@@ -6186,23 +6290,36 @@ async function testSyncProfile(api, profile) {
6186
6290
  return report;
6187
6291
  }
6188
6292
  let resolvedResultsPath = profile.resultsPath;
6189
- let records = getByDotPath(responseData, resolvedResultsPath);
6190
- if (!Array.isArray(records) && typeof responseData === "object" && responseData !== null) {
6191
- const topObj = responseData;
6192
- const arrayKey = Object.keys(topObj).find((k) => Array.isArray(topObj[k]));
6193
- if (arrayKey) {
6194
- resolvedResultsPath = arrayKey;
6195
- records = topObj[arrayKey];
6293
+ const rawCandidate = isRootPath(resolvedResultsPath) ? responseData : getByDotPath(responseData, resolvedResultsPath);
6294
+ let records = Array.isArray(rawCandidate) ? rawCandidate : null;
6295
+ if (records === null) {
6296
+ if (Array.isArray(responseData)) {
6297
+ resolvedResultsPath = "";
6298
+ records = responseData;
6196
6299
  report.autoFixed = report.autoFixed ?? {};
6197
- report.autoFixed.resultsPath = arrayKey;
6300
+ report.autoFixed.resultsPath = "";
6198
6301
  checks.push({
6199
- name: `resultsPath auto-discovered \u2192 "${arrayKey}"`,
6302
+ name: `resultsPath auto-discovered \u2192 <root>`,
6200
6303
  ok: true,
6201
- detail: `Profile had "${profile.resultsPath}" which didn't resolve; found "${arrayKey}" in response`
6304
+ detail: `Response is a root-level array \u2014 profile had "${profile.resultsPath}"`
6202
6305
  });
6306
+ } else if (typeof responseData === "object" && responseData !== null) {
6307
+ const topObj = responseData;
6308
+ const arrayKey = Object.keys(topObj).find((k) => Array.isArray(topObj[k]));
6309
+ if (arrayKey) {
6310
+ resolvedResultsPath = arrayKey;
6311
+ records = topObj[arrayKey];
6312
+ report.autoFixed = report.autoFixed ?? {};
6313
+ report.autoFixed.resultsPath = arrayKey;
6314
+ checks.push({
6315
+ name: `resultsPath auto-discovered \u2192 "${arrayKey}"`,
6316
+ ok: true,
6317
+ detail: `Profile had "${profile.resultsPath}" which didn't resolve; found "${arrayKey}" in response`
6318
+ });
6319
+ }
6203
6320
  }
6204
6321
  }
6205
- if (!Array.isArray(records)) {
6322
+ if (records === null) {
6206
6323
  const topKeys = typeof responseData === "object" && responseData !== null ? Object.keys(responseData) : [];
6207
6324
  checks.push({
6208
6325
  name: `resultsPath "${resolvedResultsPath}" \u2192 array`,
@@ -6211,10 +6328,22 @@ async function testSyncProfile(api, profile) {
6211
6328
  });
6212
6329
  return report;
6213
6330
  }
6331
+ let wrappedPrimitives = false;
6332
+ if (records.length > 0 && typeof records[0] !== "object") {
6333
+ const idField = profile.idField || "id";
6334
+ const wrapped = [];
6335
+ for (const v of records) {
6336
+ if (typeof v === "object") continue;
6337
+ wrapped.push({ [idField]: String(v) });
6338
+ }
6339
+ records = wrapped;
6340
+ wrappedPrimitives = true;
6341
+ }
6342
+ const pathLabel = isRootPath(resolvedResultsPath) ? "<root>" : `"${resolvedResultsPath}"`;
6214
6343
  checks.push({
6215
- name: `resultsPath "${resolvedResultsPath}" \u2192 array`,
6344
+ name: `resultsPath ${pathLabel} \u2192 array`,
6216
6345
  ok: true,
6217
- detail: `${records.length} records`
6346
+ detail: wrappedPrimitives ? `${records.length} primitive records (wrapped as { ${profile.idField || "id"}: value })` : `${records.length} records`
6218
6347
  });
6219
6348
  if (records.length === 0) {
6220
6349
  checks.push({ name: "sample record available", ok: false, detail: "empty result set" });
@@ -8501,7 +8630,7 @@ one --agent sync sql stripe "SELECT count(*) FROM balanceTransactions"
8501
8630
  \`sync init\` without \`--config\` does all of this automatically:
8502
8631
  - **connectionKey** \u2014 auto-resolved when there's exactly one connection for the platform
8503
8632
  - **Pagination** \u2014 Stripe id-pagination, Notion body-cursor, HubSpot/Google token, offset, link. Inapplicable fields stripped (no nextPath for offset, no passAs for none)
8504
- - **resultsPath** \u2014 generic keys (data, results, items) + platform-specific (model name stripped of platform prefix: attioCompanies \u2192 companies)
8633
+ - **resultsPath** \u2014 generic keys (data, results, items) + platform-specific (model name stripped of platform prefix: attioCompanies \u2192 companies). Use \`""\`, \`"$"\`, or \`"."\` for responses that return a bare array at the root (e.g. Hacker News \`/v0/topstories.json\`); primitive array elements are auto-wrapped as \`{ [idField]: value }\`.
8505
8634
  - **idField** \u2014 id, _id, uuid
8506
8635
  - **pathVars** \u2014 extracted from URL template with smart defaults (calendarId="primary", userId="me"). Internal keys (INTERNAL_SIGNING_KEY) and record-level IDs (record_id) are stripped automatically
8507
8636
  - **dateFilter** \u2014 updated_since, created_after, etc.
@@ -8666,7 +8795,7 @@ Fetches ALL records and deletes local rows whose IDs are no longer in the source
8666
8795
  |-------|----------|-------------|
8667
8796
  | connectionKey | yes | From \`one list\` |
8668
8797
  | actionId | yes | Auto-resolved by \`sync init\` |
8669
- | resultsPath | yes | Auto-inferred or auto-discovered by \`sync test\` |
8798
+ | resultsPath | yes | Auto-inferred or auto-discovered by \`sync test\`. Use \`""\` / \`"$"\` / \`"."\` for root-array responses |
8670
8799
  | idField | yes | Auto-inferred or auto-discovered by \`sync test\` |
8671
8800
  | pagination | yes | Auto-inferred (cursor/token/offset/id/link/none) |
8672
8801
  | pathVars | no | Auto-extracted from URL template |
@@ -8694,7 +8823,7 @@ Fetches ALL records and deletes local rows whose IDs are no longer in the source
8694
8823
  .one/sync/
8695
8824
  profiles/{platform}_{model}.json # sync profiles
8696
8825
  data/{platform}.db # SQLite databases (WAL mode)
8697
- sync_state.json # checkpoint tracking
8826
+ state/{platform}/{model}.json # per-model checkpoint tracking
8698
8827
  events/{platform}_{model}.jsonl # change event logs (if onChange: "log")
8699
8828
  logs/{platform}.log # cron run logs
8700
8829
  locks/{platform}_{model}/ # cross-process sync locks
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@withone/cli",
3
- "version": "1.37.2",
3
+ "version": "1.38.0",
4
4
  "description": "CLI for managing One",
5
5
  "type": "module",
6
6
  "files": [
@@ -16,7 +16,8 @@
16
16
  "build": "tsup",
17
17
  "dev": "tsup --watch",
18
18
  "start": "node bin/cli.js",
19
- "typecheck": "tsc --noEmit"
19
+ "typecheck": "tsc --noEmit",
20
+ "test": "tsx --test \"src/**/*.test.ts\""
20
21
  },
21
22
  "dependencies": {
22
23
  "@clack/prompts": "^0.9.1",
@@ -0,0 +1,11 @@
1
+ {
2
+ "description": "Hacker News top story IDs — up to 500 item IDs at /v0/topstories.json (root-array response, primitives wrapped as { id })",
3
+ "platform": "hacker-news",
4
+ "model": "topStories",
5
+ "connectionKey": "AUTO",
6
+ "actionId": "conn_mod_def::GJ3108Dwmm4::avAMAq7HQtW6PT8JhPg5vA",
7
+ "resultsPath": "",
8
+ "idField": "id",
9
+ "pagination": { "type": "none" },
10
+ "limitParam": ""
11
+ }