google-apis-cli 0.3.0 → 0.4.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/src/run.js CHANGED
@@ -4,13 +4,14 @@ import { join } from 'node:path';
4
4
  import { parseArgs } from 'node:util';
5
5
  import { openCatalog } from './catalog.js';
6
6
  import { configDir, openCredentials } from './credentials.js';
7
+ import { openEntities } from './entities.js';
7
8
  import { GapiError, messageOf, reasonOf } from './errors.js';
8
9
  import { isRecord } from './json.js';
9
10
  import { parseJson, plan, resolve } from './plan.js';
10
- import { AUTH_USAGE, DISCOVERY_USAGE, levelText, methodHelp, rootHelp, SCHEMA_USAGE, SEARCH_USAGE, schemaFind, schemaView, searchText, } from './render.js';
11
+ import { AUTH_USAGE, DISCOVERY_USAGE, ENTITIES_USAGE, entitiesText, FIELDS_HELP, FIELDS_USAGE, fieldsText, levelText, methodHelp, rootHelp, SCHEMA_USAGE, SEARCH_USAGE, schemaFind, schemaView, searchText, } from './render.js';
11
12
  import { MAX_AGE_MS, openResponses } from './responses.js';
12
- import { matchQuery, search } from './search.js';
13
- import { send } from './transport.js';
13
+ import { FIELD_APIS, ga4Fields, gaqlFields, matchQuery, rankFields, search, } from './search.js';
14
+ import { receive, send } from './transport.js';
14
15
  const GLOBAL_OPTIONS = {
15
16
  params: { type: 'string' },
16
17
  body: { type: 'string' },
@@ -76,7 +77,7 @@ function parse(args, options) {
76
77
  throw e instanceof GapiError ? e : new GapiError(3, messageOf(e));
77
78
  }
78
79
  }
79
- async function runAuth(sub, values, { io, creds, catalog, responses, }) {
80
+ async function runAuth(sub, values, { io, creds, catalog, responses, entities, }) {
80
81
  // --help never starts a side effect: no server, no fetch, no file write.
81
82
  if (values.help) {
82
83
  io.stdout.write(`${AUTH_USAGE}\n`);
@@ -84,7 +85,8 @@ async function runAuth(sub, values, { io, creds, catalog, responses, }) {
84
85
  }
85
86
  if (sub === 'login') {
86
87
  const consent = await consentScopes(catalog);
87
- await responses.clear(); // a new account starts with no stored responses
88
+ // A new account starts with no stored responses and no entities.
89
+ await Promise.all([responses.clear(), entities.clear()]);
88
90
  const tokens = await creds.login({ consent, manual: values.manual === true });
89
91
  io.stderr.write(`gapi: authorized (${tokens.refresh_token ? 'refresh token stored' : 'no refresh token'})\n`);
90
92
  return;
@@ -99,7 +101,7 @@ async function runAuth(sub, values, { io, creds, catalog, responses, }) {
99
101
  }
100
102
  if (sub === 'status') {
101
103
  const consent = await consentScopes(catalog);
102
- io.stdout.write(`${JSON.stringify(await creds.status(consent), null, 2)}\n`);
104
+ printJson(io, `${JSON.stringify(await creds.status(consent))}\n`);
103
105
  return;
104
106
  }
105
107
  if (sub === 'logout') {
@@ -107,13 +109,155 @@ async function runAuth(sub, values, { io, creds, catalog, responses, }) {
107
109
  await creds.logout();
108
110
  }
109
111
  finally {
110
- await responses.clear(); // also when the revoke fails: no account data stays on disk
112
+ // Also when the revoke fails: no account data stays on disk.
113
+ await Promise.all([responses.clear(), entities.clear()]);
111
114
  }
112
115
  io.stderr.write('gapi: credentials removed\n');
113
116
  return;
114
117
  }
115
118
  throw new GapiError(3, sub === undefined ? `usage: ${AUTH_USAGE}` : `unknown auth command: ${sub}`);
116
119
  }
120
+ // #200 D6: `sync` stores each API's entity sources; `find` searches them offline.
121
+ async function runEntities(args, { io, creds, catalog, entities }) {
122
+ const parsed = parse(args, {
123
+ help: { type: 'boolean' },
124
+ api: { type: 'string' },
125
+ 'page-limit': { type: 'string' },
126
+ });
127
+ const { values } = parsed;
128
+ const [sub, ...positionals] = parsed.positionals;
129
+ if (values.help) {
130
+ io.stdout.write(`${ENTITIES_USAGE}\n`);
131
+ return;
132
+ }
133
+ if (sub !== 'find' && sub !== 'sync')
134
+ throw new GapiError(3, `usage: ${ENTITIES_USAGE}`);
135
+ const other = sub === 'find' ? 'page-limit' : 'api';
136
+ if (values[other] !== undefined)
137
+ throw new GapiError(3, `--${other}: not an option of ${sub}`);
138
+ const identity = await creds.identity({ kind: 'oauth', scopes: [] });
139
+ const sourcesOf = (api) => {
140
+ if (!Object.hasOwn(catalog.entries, api))
141
+ throw new GapiError(3, `unknown api: ${api}`);
142
+ const sources = catalog.entries[api].entities ?? [];
143
+ if (sources.length === 0)
144
+ throw new GapiError(3, `${api} has no entity sources`);
145
+ return sources;
146
+ };
147
+ if (sub === 'find') {
148
+ const api = typeof values.api === 'string' ? values.api : undefined;
149
+ if (api !== undefined)
150
+ sourcesOf(api);
151
+ const { hits, empty } = await entities.find(positionals.join(' '), { api, identity });
152
+ if (empty)
153
+ io.stderr.write('gapi: entities index is empty or older than 7 days; run gapi entities sync\n');
154
+ if (hits.length === 0)
155
+ io.stderr.write('gapi: no match\n');
156
+ io.stdout.write(entitiesText(hits));
157
+ return;
158
+ }
159
+ const limit = typeof values['page-limit'] === 'string' ? values['page-limit'] : '10';
160
+ if (!/^[1-9]\d*$/.test(limit))
161
+ throw new GapiError(3, '--page-limit must be a positive integer');
162
+ const apis = positionals.length > 0
163
+ ? [...new Set(positionals)]
164
+ : Object.keys(catalog.entries)
165
+ .filter((a) => catalog.entries[a].entities?.length)
166
+ .toSorted();
167
+ const sources = apis.map((a) => ({ api: a, sources: sourcesOf(a) }));
168
+ if (identity === undefined)
169
+ throw new GapiError(2, 'no refresh token; run gapi auth login');
170
+ // Cost first: one call per top-level source, then one per parent entity of a child source.
171
+ const children = new Set();
172
+ const each = (api, list) => {
173
+ for (const s of list) {
174
+ for (const [kind, child] of Object.entries(s.children ?? {})) {
175
+ children.add(`${api} ${kind}`);
176
+ each(api, [child]);
177
+ }
178
+ }
179
+ };
180
+ for (const s of sources)
181
+ each(s.api, s.sources);
182
+ const top = sources.reduce((n, s) => n + s.sources.length, 0);
183
+ const then = [...children].map((c) => `, then 1 per ${c}`).join('');
184
+ io.stderr.write(`gapi: entities sync: ${top} list calls${then}; max ${limit} pages per list\n`);
185
+ let failed = 0;
186
+ for (const { api, sources: list } of sources) {
187
+ try {
188
+ // A source may call another entry's method (`api`); its settings and headers apply.
189
+ const request = async function* ({ api: owner = api, method, params, body, headers, }) {
190
+ const model = await catalog.load(owner);
191
+ const settings = await creds.settings({}, model.headers);
192
+ const paged = model.byId.get(method)?.paging !== undefined;
193
+ const call = plan(model, method, { params, body, pageAll: paged, pageLimit: paged ? limit : undefined }, { ...settings, headers: { ...settings.headers, ...headers } });
194
+ for (const w of call.warnings)
195
+ io.stderr.write(`gapi: warning: ${w}\n`);
196
+ yield* receive(call, { fetch: io.fetch, authorize: creds.authorize, sleep: io.sleep });
197
+ };
198
+ const n = await entities.sync(api, list, identity, request);
199
+ io.stdout.write(`${api}: ${n} entities\n`);
200
+ }
201
+ catch (e) {
202
+ failed++;
203
+ io.stdout.write(`${api}: failed, old rows kept: ${messageOf(e)}\n`);
204
+ }
205
+ }
206
+ if (failed > 0)
207
+ throw new GapiError(1, `${failed} of ${sources.length} entity sources failed`);
208
+ }
209
+ // #200 D7: GAQL names offline from the discovery document, GA4 names from one getMetadata call.
210
+ async function runFields(args, { io, creds, catalog }) {
211
+ const { values, positionals } = parse(args, {
212
+ property: { type: 'string' },
213
+ cache: { type: 'string' },
214
+ help: { type: 'boolean' },
215
+ });
216
+ if (values.help) {
217
+ io.stdout.write(`${FIELDS_HELP}\n`);
218
+ return;
219
+ }
220
+ // ADR 0001 keys a cache entry by the request, so two queries would share one entry.
221
+ if (values.cache !== undefined)
222
+ throw new GapiError(3, '--cache: fields is not cacheable');
223
+ const [name, ...words] = positionals;
224
+ if (name === undefined)
225
+ throw new GapiError(3, `usage: ${FIELDS_USAGE}`);
226
+ if (!FIELD_APIS.includes(name)) {
227
+ throw new GapiError(3, `fields: ${name} has no field source; supported: ${FIELD_APIS.join(', ')}`);
228
+ }
229
+ const query = words.join(' ');
230
+ matchQuery(query, FIELDS_USAGE); // no word → exit 3 before any document loads
231
+ const { property } = values;
232
+ if (name !== 'ga4-data' && property !== undefined) {
233
+ throw new GapiError(3, `--property: not an option of fields ${name}`);
234
+ }
235
+ if (name === 'ga4-data' && property === undefined) {
236
+ throw new GapiError(3, '--property is required');
237
+ }
238
+ const id = property?.replace(/^properties\//, '');
239
+ if (id !== undefined && !/^\d+$/.test(id)) {
240
+ throw new GapiError(3, `--property: ${property} is not a GA4 property id`);
241
+ }
242
+ const model = await catalog.load(name);
243
+ let fields = [];
244
+ if (id === undefined) {
245
+ fields = gaqlFields(model);
246
+ }
247
+ else {
248
+ const settings = await creds.settings({}, model.headers);
249
+ const call = plan(model, 'analyticsdata.properties.getMetadata', { params: { name: `properties/${id}/metadata` } }, settings);
250
+ for (const w of call.warnings)
251
+ io.stderr.write(`gapi: warning: ${w}\n`);
252
+ const opts = { fetch: io.fetch, authorize: creds.authorize, sleep: io.sleep };
253
+ for await (const data of receive(call, opts))
254
+ fields = ga4Fields(data);
255
+ }
256
+ const hits = rankFields(fields, query);
257
+ if (hits.length === 0)
258
+ io.stderr.write('gapi: no match\n');
259
+ io.stdout.write(fieldsText(hits));
260
+ }
117
261
  // D8 consent set: every entry's per-method scope plus its entry scopes (identity is added by login).
118
262
  async function consentScopes(catalog) {
119
263
  const models = await Promise.all(Object.keys(catalog.entries).map((api) => catalog.load(api)));
@@ -159,6 +303,9 @@ async function dispatch(argv, io) {
159
303
  });
160
304
  const responses = openResponses(join(dir, 'responses'));
161
305
  await responses.sweep();
306
+ const entities = openEntities(join(dir, 'entities.db'));
307
+ // shortcut: a lock or disk error skips this run's sweep with a warning, the next run retries.
308
+ await entities.sweep().catch((e) => warn(`entities sweep skipped: ${messageOf(e)}`));
162
309
  // ADR 0001 eligibility, before any network call: the entry allows caching, the call is
163
310
  // one request or pages, the method is a GET or a `reads` POST.
164
311
  const cacheFor = async (call, model, values) => {
@@ -187,8 +334,10 @@ async function dispatch(argv, io) {
187
334
  manual: { type: 'boolean' },
188
335
  help: { type: 'boolean' },
189
336
  });
190
- return runAuth(positionals[0], values, { io, creds, catalog, responses });
337
+ return runAuth(positionals[0], values, { io, creds, catalog, responses, entities });
191
338
  }
339
+ if (api === 'entities')
340
+ return runEntities(rest, { io, creds, catalog, entities });
192
341
  if (api === 'search') {
193
342
  // No option parse: a word like `-list` is query text (story 11).
194
343
  if (rest.includes('--help')) {
@@ -204,6 +353,8 @@ async function dispatch(argv, io) {
204
353
  io.stdout.write(searchText(hits));
205
354
  return;
206
355
  }
356
+ if (api === 'fields')
357
+ return runFields(rest, { io, creds, catalog });
207
358
  if (api === 'schema') {
208
359
  const { values, positionals } = parse(rest, {
209
360
  'resolve-refs': { type: 'boolean' },
@@ -322,6 +473,24 @@ async function dispatch(argv, io) {
322
473
  const cache = await cacheFor(call, model, values);
323
474
  return perform(call, dryRun, io, creds.authorize, { responses, cache });
324
475
  }
476
+ // The one JSON writer (epic #200 D2): compact lines stay as they are off a terminal (agents), each
477
+ // line gets a 2-space indent on a terminal (operators).
478
+ function printJson(io, lines) {
479
+ if (!io.stdout.isTTY)
480
+ return io.stdout.write(lines);
481
+ let text;
482
+ try {
483
+ const values = lines
484
+ .split('\n')
485
+ .filter(Boolean)
486
+ .map((l) => JSON.parse(l));
487
+ text = values.map((v) => `${JSON.stringify(v, null, 2)}\n`).join('');
488
+ }
489
+ catch {
490
+ text = lines; // a cache entry stored before #205 is already indented
491
+ }
492
+ return io.stdout.write(text);
493
+ }
325
494
  // Prints the plan's warnings, then the redacted request (--dry-run), the stored response
326
495
  // (a --cache hit) or the live response, which a --cache miss stores after a full success.
327
496
  async function perform(call, dryRun, io, authorize, { responses, cache }) {
@@ -349,11 +518,11 @@ async function perform(call, dryRun, io, authorize, { responses, cache }) {
349
518
  : undefined,
350
519
  cache: cache && (stored === undefined ? 'miss' : 'hit'),
351
520
  };
352
- io.stdout.write(`${JSON.stringify(request, null, 2)}\n`);
521
+ printJson(io, `${JSON.stringify(request)}\n`);
353
522
  return;
354
523
  }
355
524
  if (stored !== undefined && mtime !== undefined) {
356
- io.stdout.write(stored);
525
+ printJson(io, stored);
357
526
  io.stderr.write(`gapi: cached response from ${new Date(mtime).toISOString()}\n`);
358
527
  return;
359
528
  }
@@ -361,7 +530,7 @@ async function perform(call, dryRun, io, authorize, { responses, cache }) {
361
530
  const out = {
362
531
  write: (chunk) => {
363
532
  chunks.push(chunk);
364
- return io.stdout.write(chunk);
533
+ return printJson(io, chunk);
365
534
  },
366
535
  };
367
536
  await send(call, { fetch: io.fetch, authorize, out, sleep: io.sleep });
@@ -1,27 +1,81 @@
1
+ var __addDisposableResource = (this && this.__addDisposableResource) || function (env, value, async) {
2
+ if (value !== null && value !== void 0) {
3
+ if (typeof value !== "object" && typeof value !== "function") throw new TypeError("Object expected.");
4
+ var dispose, inner;
5
+ if (async) {
6
+ if (!Symbol.asyncDispose) throw new TypeError("Symbol.asyncDispose is not defined.");
7
+ dispose = value[Symbol.asyncDispose];
8
+ }
9
+ if (dispose === void 0) {
10
+ if (!Symbol.dispose) throw new TypeError("Symbol.dispose is not defined.");
11
+ dispose = value[Symbol.dispose];
12
+ if (async) inner = dispose;
13
+ }
14
+ if (typeof dispose !== "function") throw new TypeError("Object not disposable.");
15
+ if (inner) dispose = function() { try { inner.call(this); } catch (e) { return Promise.reject(e); } };
16
+ env.stack.push({ value: value, dispose: dispose, async: async });
17
+ }
18
+ else if (async) {
19
+ env.stack.push({ async: true });
20
+ }
21
+ return value;
22
+ };
23
+ var __disposeResources = (this && this.__disposeResources) || (function (SuppressedError) {
24
+ return function (env) {
25
+ function fail(e) {
26
+ env.error = env.hasError ? new SuppressedError(e, env.error, "An error was suppressed during disposal.") : e;
27
+ env.hasError = true;
28
+ }
29
+ var r, s = 0;
30
+ function next() {
31
+ while (r = env.stack.pop()) {
32
+ try {
33
+ if (!r.async && s === 1) return s = 0, env.stack.push(r), Promise.resolve().then(next);
34
+ if (r.dispose) {
35
+ var result = r.dispose.call(r.value);
36
+ if (r.async) return s |= 2, Promise.resolve(result).then(next, function(e) { fail(e); return next(); });
37
+ }
38
+ else s |= 1;
39
+ }
40
+ catch (e) {
41
+ fail(e);
42
+ }
43
+ }
44
+ if (s === 1) return env.hasError ? Promise.reject(env.error) : Promise.resolve();
45
+ if (env.hasError) throw env.error;
46
+ }
47
+ return next();
48
+ };
49
+ })(typeof SuppressedError === "function" ? SuppressedError : function (error, suppressed, message) {
50
+ var e = new Error(message);
51
+ return e.name = "SuppressedError", e.error = error, e.suppressed = suppressed, e;
52
+ });
1
53
  // Search: finds methods by intent across API entries with an in-memory SQLite FTS5 index (#182 §1).
2
54
  // shortcut: DatabaseSync is a documentation-only deprecated alias of Database since Node v26.11.0
3
55
  // (DEP0210); switch when @types/node types Database.
4
56
  import { DatabaseSync } from 'node:sqlite';
5
57
  import { GapiError } from './errors.js';
6
- import { SEARCH_USAGE } from './render.js';
58
+ import { isRecord } from './json.js';
59
+ import { brief, FIELDS_USAGE, SEARCH_USAGE } from './render.js';
7
60
  // `conversionActions` → `conversion Actions`, `URLInspection` → `URL Inspection`, `a-b_c.d` → `a b c d`.
8
61
  const split = (text) => text
9
62
  .replace(/([\p{Ll}\p{N}])(\p{Lu})/gu, '$1 $2')
10
63
  .replace(/(\p{Lu})(\p{Lu}\p{Ll})/gu, '$1 $2')
11
64
  .replace(/[-_.]/g, ' ');
12
65
  // Tokens hold only letters and digits, so no FTS5 syntax reaches the match expression.
13
- export function matchQuery(query) {
66
+ export function matchQuery(query, usage = SEARCH_USAGE) {
14
67
  const tokens = split(query)
15
68
  .toLowerCase()
16
69
  .match(/[\p{L}\p{N}]+/gu);
17
70
  if (!tokens)
18
- throw new GapiError(3, `usage: ${SEARCH_USAGE}`);
71
+ throw new GapiError(3, `usage: ${usage}`);
19
72
  return tokens.map((t) => `"${t}"`).join(' OR ');
20
73
  }
21
74
  export function search(models, query) {
22
- const match = matchQuery(query);
23
- const db = new DatabaseSync(':memory:');
75
+ const env_1 = { stack: [], error: void 0, hasError: false };
24
76
  try {
77
+ const match = matchQuery(query);
78
+ const db = __addDisposableResource(env_1, new DatabaseSync(':memory:'), false);
25
79
  db.exec("create virtual table m using fts5(api unindexed, id unindexed, words, extra, descr, tokenize = 'porter unicode61')");
26
80
  const insert = db.prepare('insert into m values (?, ?, ?, ?, ?)');
27
81
  db.exec('begin');
@@ -47,7 +101,108 @@ export function search(models, query) {
47
101
  return model && method ? [{ model, method }] : [];
48
102
  });
49
103
  }
104
+ catch (e_1) {
105
+ env_1.error = e_1;
106
+ env_1.hasError = true;
107
+ }
108
+ finally {
109
+ __disposeResources(env_1);
110
+ }
111
+ }
112
+ export const FIELD_APIS = ['ads', 'ga4-data', 'sa360'];
113
+ const snake = (camel) => split(camel).replaceAll(' ', '_').toLowerCase();
114
+ // Proto field behavior marks open most Ads descriptions; the sentence after them says more.
115
+ const FIELD_MARKS = /^(?:(?:Output only|Immutable|Required|Optional)\.\s*)+/;
116
+ const GAQL_ROOT = /(?:Resources__(\w+)|Common__(Metrics|Segments))$/;
117
+ const isResource = (ref) => typeof ref === 'string' && /Resources__/.test(ref);
118
+ // Singular message fields expand to their leaves (`campaign.network_settings.target_google_search`);
119
+ // arrays stay one field. A message that only holds other resources stays one field, as
120
+ // `change_event.old_resource` in Google's GAQL examples.
121
+ // shortcut: names follow the discovery shape, not Google's selectable list; selectability
122
+ // (`selectable_with`) needs googleAdsFields.search.
123
+ export function gaqlFields(model) {
124
+ const fields = [];
125
+ const walk = (schema, path, kind, seen) => {
126
+ const props = isRecord(schema.properties) ? Object.entries(schema.properties) : [];
127
+ for (const [key, prop] of props) {
128
+ if (!isRecord(prop))
129
+ continue;
130
+ const name = `${path}.${snake(key)}`;
131
+ const ref = typeof prop.$ref === 'string' ? prop.$ref : undefined;
132
+ const target = ref && !seen.includes(ref) ? model.schemas.get(ref) : undefined;
133
+ const inner = isRecord(target?.properties) ? Object.values(target.properties) : [];
134
+ if (target && ref && !inner.every((p) => isRecord(p) && isResource(p.$ref))) {
135
+ walk(target, name, kind, [...seen, ref]);
136
+ }
137
+ else {
138
+ const text = typeof prop.description === 'string' ? prop.description : '';
139
+ fields.push({ name, kind, description: text.replace(FIELD_MARKS, '') });
140
+ }
141
+ }
142
+ };
143
+ for (const [id, schema] of model.schemas) {
144
+ const m = GAQL_ROOT.exec(id);
145
+ if (!m)
146
+ continue;
147
+ const root = m[1] ? snake(m[1]) : m[2].toLowerCase();
148
+ const kind = m[1] ? 'attribute' : m[2] === 'Metrics' ? 'metric' : 'segment';
149
+ walk(schema, root, kind, [id]);
150
+ }
151
+ return fields;
152
+ }
153
+ // GA4 Metadata: dimensions and metrics; the UI name and deprecated names rank too.
154
+ export function ga4Fields(metadata) {
155
+ const list = (key, kind) => {
156
+ const items = isRecord(metadata) && Array.isArray(metadata[key]) ? metadata[key] : [];
157
+ return items.filter(isRecord).flatMap((d) => {
158
+ if (typeof d.apiName !== 'string')
159
+ return [];
160
+ const old = Array.isArray(d.deprecatedApiNames) ? d.deprecatedApiNames : [];
161
+ const words = [d.uiName, ...old].filter((w) => typeof w === 'string').join(' ');
162
+ const description = typeof d.description === 'string' ? d.description : undefined;
163
+ return [{ name: d.apiName, kind, words, description }];
164
+ });
165
+ };
166
+ return [...list('dimensions', 'dimension'), ...list('metrics', 'metric')];
167
+ }
168
+ // FTS5 bm25 normalizes by the whole row's length, so names and descriptions sit in two tables:
169
+ // a long description does not bury a short name. A description match counts a third.
170
+ export function rankFields(fields, query) {
171
+ const env_2 = { stack: [], error: void 0, hasError: false };
172
+ try {
173
+ const match = matchQuery(query, FIELDS_USAGE);
174
+ const db = __addDisposableResource(env_2, new DatabaseSync(':memory:'), false);
175
+ db.exec(`create virtual table n using fts5(i unindexed, name, words, tokenize = 'porter unicode61');
176
+ create virtual table d using fts5(i unindexed, descr, tokenize = 'porter unicode61')`);
177
+ const names = db.prepare('insert into n values (?, ?, ?)');
178
+ const descrs = db.prepare('insert into d values (?, ?)');
179
+ db.exec('begin');
180
+ fields.forEach((f, i) => {
181
+ // Each name word once: a nested path repeats words (`budget…budget`).
182
+ names.run(i, [...new Set(split(f.name).toLowerCase().split(' '))].join(' '), split(f.words ?? ''));
183
+ descrs.run(i, brief(f.description));
184
+ });
185
+ db.exec('commit');
186
+ const ranked = db
187
+ .prepare(`select i, sum(s) as score from (
188
+ select i, bm25(n, 0, 2, 1) as s from n where n match ?
189
+ union all select i, bm25(d) / 3 from d where d match ?)
190
+ group by i order by score, i`)
191
+ .all(match, match)
192
+ .map(({ i }) => fields[Number(i)]);
193
+ // Fields whose last name part is exactly the query come first, in rank order.
194
+ const key = (text) => split(text)
195
+ .toLowerCase()
196
+ .match(/[\p{L}\p{N}]+/gu)
197
+ ?.join(' ');
198
+ const exact = (f) => key(f.name.slice(f.name.lastIndexOf('.') + 1)) === key(query);
199
+ return [...ranked.filter(exact), ...ranked.filter((f) => !exact(f))].slice(0, 20);
200
+ }
201
+ catch (e_2) {
202
+ env_2.error = e_2;
203
+ env_2.hasError = true;
204
+ }
50
205
  finally {
51
- db.close();
206
+ __disposeResources(env_2);
52
207
  }
53
208
  }
@@ -1,4 +1,4 @@
1
- // Transport: sends a CallPlan and prints the response (epic #30 D13).
1
+ // Transport: sends a CallPlan and yields or prints its responses (epic #30 D13, #200 D3).
2
2
  // `authorize` adds the credential before every request; a non-2xx exits 1, a missing scope 2.
3
3
  import { setTimeout } from 'node:timers/promises';
4
4
  import { GapiError, reasonOf } from './errors.js';
@@ -7,15 +7,23 @@ import { batch } from './transport/batch.js';
7
7
  import { download } from './transport/download.js';
8
8
  import { backoff, NetworkError, RETRIES, retryAfter, retryable, } from './transport/retry.js';
9
9
  import { upload } from './transport/upload.js';
10
+ // Prints each response as one compact JSON line; `run` picks the final form (epic #200 D2).
10
11
  export async function send(plan, deps) {
12
+ for await (const data of receive(plan, deps)) {
13
+ deps.out.write(`${JSON.stringify(data)}\n`);
14
+ }
15
+ }
16
+ // Yields each decoded response of a plan; a decode result of `undefined` is not yielded.
17
+ // A batch yields its results before it throws a failure: the parts sent were acted on.
18
+ export async function* receive(plan, deps) {
11
19
  const call = caller(plan, deps);
12
- const print = (data) => deps.out.write(`${JSON.stringify(data, null, 2)}\n`);
20
+ const decode = (data, step) => (plan.decode ? plan.decode(data, step) : data);
13
21
  if (plan.send === 'upload') {
14
- print(await json(await upload(plan, call, deps.sleep ?? setTimeout)));
22
+ yield* some(decode(await json(await upload(plan, call, deps.sleep ?? setTimeout))));
15
23
  return;
16
24
  }
17
25
  if (plan.send === 'download') {
18
- print(await download(plan, call));
26
+ yield* some(decode(await download(plan, call)));
19
27
  return;
20
28
  }
21
29
  if (plan.send === 'batch') {
@@ -24,9 +32,8 @@ export async function send(plan, deps) {
24
32
  status,
25
33
  body: text ? parseOrText(text) : undefined,
26
34
  }));
27
- // Results print before any failure: the parts already sent were acted on.
28
35
  if (results.length > 0 || !sent.error)
29
- print(plan.decode ? plan.decode(results) : results);
36
+ yield* some(decode(results));
30
37
  if (sent.error)
31
38
  throw sent.error;
32
39
  const failed = sent.results.filter((r) => r.status < 200 || r.status > 299);
@@ -39,41 +46,41 @@ export async function send(plan, deps) {
39
46
  }
40
47
  return;
41
48
  }
42
- const init = {
43
- method: plan.httpMethod,
49
+ const fetchStep = async (step) => json(await call(step.url, {
50
+ method: step.httpMethod,
44
51
  headers: plan.headers,
45
- body: plan.body === undefined ? undefined : JSON.stringify(plan.body),
46
- };
52
+ body: step.body === undefined ? undefined : JSON.stringify(step.body),
53
+ }));
54
+ let step = { httpMethod: plan.httpMethod, url: plan.url, body: plan.body };
47
55
  if (plan.send === 'once') {
48
- const data = await json(await call(plan.url, init));
49
- print(plan.decode ? plan.decode(data) : data);
56
+ yield* some(decode(await fetchStep(step), step));
50
57
  return;
51
58
  }
52
- // D6: one compact JSON line per page, until no nextPageToken or pageLimit pages. The next
53
- // page repeats the call with only pageToken changed (AIP-158), in the query or the body.
54
- const url = new URL(plan.url);
55
- let pageInit = init;
56
- for (let page = 1; page <= plan.pageLimit; page++) {
57
- const data = await json(await call(url.href, pageInit));
58
- deps.out.write(`${JSON.stringify(data)}\n`);
59
- const token = isRecord(data) ? data.nextPageToken : undefined;
60
- if (typeof token !== 'string' || !token)
61
- return;
62
- if (plan.paging === 'query') {
63
- url.searchParams.set('pageToken', token);
64
- }
65
- else {
66
- pageInit = { ...init, body: JSON.stringify({ ...plan.body, pageToken: token }) };
59
+ // D3: follow the rule until it stops or pageLimit responses were yielded.
60
+ for (let yielded = 0; step && yielded < plan.pageLimit;) {
61
+ const data = await fetchStep(step);
62
+ const decoded = decode(data, step);
63
+ if (decoded !== undefined) {
64
+ yielded++;
65
+ yield decoded;
67
66
  }
67
+ step = plan.follow(data, step);
68
68
  }
69
69
  }
70
+ function* some(data) {
71
+ if (data !== undefined)
72
+ yield data;
73
+ }
70
74
  // Uploads and batches never retry here; a resumable upload resumes on its own.
71
- function caller(plan, { fetch, authorize, sleep = setTimeout }) {
75
+ function caller(plan, { fetch, authorize, signal, sleep = (ms) => setTimeout(ms, undefined, { signal }) }) {
72
76
  const retry = plan.retry && plan.send !== 'upload' && plan.send !== 'batch';
73
77
  return async (url, init, allowed) => {
74
78
  for (let attempt = 0;; attempt++) {
79
+ signal?.throwIfAborted(); // a deadline that passed during a backoff stops the retries
75
80
  const headers = { ...init.headers, ...(await authorize(plan.auth)) };
76
- const res = await fetch(url, { ...init, headers }).catch((e) => {
81
+ const res = await fetch(url, { ...init, headers, ...(signal && { signal }) }).catch((e) => {
82
+ if (signal?.aborted)
83
+ throw signal.reason; // a deadline is never retried
77
84
  const cause = e instanceof Error && e.cause !== undefined ? e.cause : e;
78
85
  return new NetworkError(1, `network: ${reasonOf(cause)}`);
79
86
  });
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "google-apis-cli",
3
- "version": "0.3.0",
4
- "description": "One discovery-driven CLI (gapi) for 47 Google marketing and data APIs: Ads, GA4, Search Console, Tag Manager, Indexing, Merchant, Business Profile, SA360, CM360, DV360, YouTube, PageSpeed, CrUX, Sheets, Places and more. One login, read+write.",
3
+ "version": "0.4.1",
4
+ "description": "One discovery-driven CLI (gapi) for 49 Google marketing and data APIs: Ads, GA4, Search Console, Tag Manager, Indexing, Merchant, Business Profile, SA360, CM360, DV360, YouTube, PageSpeed, CrUX, BigQuery, Sheets, Places and more. One login, read+write.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "gapi": "dist/bin/gapi.js"
@@ -55,13 +55,13 @@
55
55
  "url": "https://github.com/realroboto/google-apis-cli/issues"
56
56
  },
57
57
  "dependencies": {
58
- "google-auth-library": "^11.3.3"
58
+ "google-auth-library": "^11.3.4"
59
59
  },
60
60
  "license": "MIT",
61
61
  "devDependencies": {
62
- "@biomejs/biome": "^2.6.5",
62
+ "@biomejs/biome": "^2.6.7",
63
63
  "@types/node": "^26.6.6",
64
64
  "typescript": "^7.0.2"
65
65
  },
66
- "packageManager": "pnpm@12.16.1+sha512.091d1dee7927575661b25dab3c2ae31cafc1c54d6b71ed6a6600cf0c1cb10434a447eae14015f1b23e2e6bedeed79df94a932e2164d2832280dce3fb32f656d1"
66
+ "packageManager": "pnpm@12.18.0+sha512.9d7882d5dc82147ecfc4e062aa34834e0ad0296710a1837a1115ae012127d36b7109e321ac1598a1dd0a65597681b1e99917e12ed0d749eae91187943363beb3"
67
67
  }