@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 +1 -1
- package/dist/index.js +177 -48
- package/package.json +3 -2
- package/profiles/hacker-news/topStories.json +11 -0
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", "
|
|
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
|
|
4857
|
-
|
|
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
|
-
|
|
4860
|
-
const
|
|
4861
|
-
|
|
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
|
-
|
|
4877
|
+
try {
|
|
4878
|
+
fs9.unlinkSync(LEGACY_STATE_FILE);
|
|
4879
|
+
} catch {
|
|
4880
|
+
}
|
|
4864
4881
|
}
|
|
4865
4882
|
}
|
|
4866
|
-
function
|
|
4867
|
-
|
|
4868
|
-
|
|
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,
|
|
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
|
-
|
|
4874
|
-
return
|
|
4929
|
+
migrateLegacyIfNeeded();
|
|
4930
|
+
return readModelFile(platform, model);
|
|
4875
4931
|
}
|
|
4876
4932
|
function updateModelState(platform, model, partial) {
|
|
4877
|
-
|
|
4878
|
-
|
|
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
|
-
|
|
4888
|
-
writeSyncState(state);
|
|
4942
|
+
writeModelFile(platform, model, { ...existing, ...partial });
|
|
4889
4943
|
}
|
|
4890
4944
|
function removeModelState(platform, model) {
|
|
4891
|
-
|
|
4892
|
-
|
|
4945
|
+
migrateLegacyIfNeeded();
|
|
4946
|
+
const platformDir = path9.join(STATE_DIR, platform);
|
|
4893
4947
|
if (model) {
|
|
4894
|
-
|
|
4895
|
-
|
|
4896
|
-
|
|
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
|
-
|
|
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
|
|
5885
|
-
|
|
5886
|
-
|
|
5887
|
-
|
|
5888
|
-
|
|
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
|
-
|
|
6190
|
-
|
|
6191
|
-
|
|
6192
|
-
|
|
6193
|
-
|
|
6194
|
-
|
|
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 =
|
|
6300
|
+
report.autoFixed.resultsPath = "";
|
|
6198
6301
|
checks.push({
|
|
6199
|
-
name: `resultsPath auto-discovered \u2192
|
|
6302
|
+
name: `resultsPath auto-discovered \u2192 <root>`,
|
|
6200
6303
|
ok: true,
|
|
6201
|
-
detail: `
|
|
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 (
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
+
}
|