drivethrough-cli 0.1.12 → 0.1.14

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.
Files changed (2) hide show
  1. package/dist/main.mjs +514 -245
  2. package/package.json +1 -1
package/dist/main.mjs CHANGED
@@ -1065,173 +1065,35 @@ var init_universal = __esm({
1065
1065
  "use strict";
1066
1066
  UNIVERSAL_SCHEMA_STATEMENTS = [
1067
1067
  `CREATE TABLE IF NOT EXISTS data (
1068
- id TEXT PRIMARY KEY,
1069
- parent_id TEXT,
1070
- type TEXT,
1071
- json TEXT,
1072
- created_at TEXT
1068
+ seq INTEGER PRIMARY KEY AUTOINCREMENT,
1069
+ type TEXT NOT NULL,
1070
+ parent_seq INTEGER REFERENCES data(seq) ON DELETE CASCADE,
1071
+ json TEXT NOT NULL DEFAULT '{}',
1072
+ session TEXT,
1073
+ token TEXT,
1074
+ created_at INTEGER NOT NULL,
1075
+ updated_at INTEGER NOT NULL
1073
1076
  )`,
1074
- // ---- platform indexes ------------------------------------------------------
1075
- // Named `dt_*`, never `idx_*`. generatedColumnName() emits `idx_<type>_<field>`
1076
- // for a site's DECLARED indexes, so a site declaring type "type" with an index
1077
- // on "$.recent" would land on `idx_type_recent` — and applyDeclaredIndexes only
1078
- // checks for a colliding COLUMN, not a colliding INDEX, so its
1079
- // `CREATE INDEX IF NOT EXISTS` would silently no-op and the site's declared
1080
- // index would never exist. `dt_` is unreachable from that generator.
1081
- //
1082
- // Each statement pair DROPs the previous name before creating the current one:
1083
- // `CREATE ... IF NOT EXISTS` would otherwise see the old index and keep it, so
1084
- // databases provisioned earlier would never upgrade. Once upgraded both are
1085
- // no-ops, which keeps init() cheap to re-run.
1086
- // (type, created_at), not (type). Every query against this table leads with
1087
- // `type`, and the list queries all end `ORDER BY created_at DESC LIMIT n`.
1088
- // On the single column, SQLite filtered by type and then sorted EVERY row of
1089
- // that type to honour the ORDER BY — a 10k-row type returning 100 read 10k.
1090
- // The composite serves the filter exactly as before and satisfies the sort by
1091
- // walking the index backwards, stopping at the LIMIT. It is still one index,
1092
- // and D1 bills a written row per index touched rather than per column, so the
1093
- // read win costs nothing on writes.
1094
- `DROP INDEX IF EXISTS idx_type`,
1095
- `CREATE INDEX IF NOT EXISTS dt_type_recent ON data(type, created_at)`,
1096
- // PARTIAL. A full index on parent_id charged every insert for an entry the
1097
- // common case never reads: most rows are top-level (a form submission with no
1098
- // parent), and `WHERE parent_id = ?` can only match a non-null value.
1099
- // Excluding NULLs keeps every query that used it — `type = ?1 AND
1100
- // parent_id = ?2` — on an index, while top-level inserts stop paying for it.
1101
- //
1102
- // The one reader of NULL parents (selectSessionRow's `parent_id IS NULL`
1103
- // branch) cannot use a partial index, and did not want this one anyway: it
1104
- // filters on type first, and for the unique_per types it actually runs on,
1105
- // uniq_session_<type> covers it.
1106
- `DROP INDEX IF EXISTS idx_parent`,
1107
- `DROP INDEX IF EXISTS idx_parent_present`,
1108
- `CREATE INDEX IF NOT EXISTS dt_parent_present ON data(parent_id) WHERE parent_id IS NOT NULL`
1077
+ // ---- the three shared indexes ---------------------------------------------
1078
+ // `(type)`, NOT `(type, seq)`. Because seq is INTEGER PRIMARY KEY it is the
1079
+ // rowid, and SQLite appends the rowid to every index entry — so an index on
1080
+ // `(type)` is already stored as `type, seq` and the ordering is free. Writing
1081
+ // `(type, seq)` stores the number twice: 1,708 KB against 1,348 KB per
1082
+ // 100,000 rows, for identical behaviour (schema §8).
1083
+ `CREATE INDEX IF NOT EXISTS dt_type ON data(type)`,
1084
+ // PARTIAL, so top-level rows (the common case — a form submission with no
1085
+ // parent) never pay for an index entry `WHERE parent_seq = ?` could not match
1086
+ // anyway.
1087
+ `CREATE INDEX IF NOT EXISTS dt_parent ON data(parent_seq) WHERE parent_seq IS NOT NULL`,
1088
+ // PARTIAL for the same reason, and it is what makes shipping `token` now free
1089
+ // for every row that doesn't use it: 2 billed writes without a token, 3 with
1090
+ // (schema §4).
1091
+ `CREATE UNIQUE INDEX IF NOT EXISTS dt_token ON data(token) WHERE token IS NOT NULL`
1109
1092
  ];
1110
1093
  UNIVERSAL_SCHEMA_SQL = UNIVERSAL_SCHEMA_STATEMENTS.join(";\n") + ";";
1111
1094
  }
1112
1095
  });
1113
1096
 
1114
- // ../schema/src/names.ts
1115
- var RESERVED_NAMES, RESERVED;
1116
- var init_names = __esm({
1117
- "../schema/src/names.ts"() {
1118
- "use strict";
1119
- RESERVED_NAMES = [
1120
- // platform & infrastructure
1121
- "drivethrough",
1122
- "drivethru",
1123
- "www",
1124
- "mail",
1125
- "smtp",
1126
- "imap",
1127
- "ftp",
1128
- "ns1",
1129
- "ns2",
1130
- "api",
1131
- "cdn",
1132
- "static",
1133
- "assets",
1134
- "status",
1135
- "docs",
1136
- "blog",
1137
- "dev",
1138
- "app",
1139
- // auth & account impersonation
1140
- "login",
1141
- "logout",
1142
- "signin",
1143
- "signup",
1144
- "auth",
1145
- "authorize",
1146
- "oauth",
1147
- "sso",
1148
- "secure",
1149
- "security",
1150
- "verify",
1151
- "verification",
1152
- "activate",
1153
- "session",
1154
- "account",
1155
- "accounts",
1156
- "admin",
1157
- "administrator",
1158
- "root",
1159
- "password",
1160
- "passwords",
1161
- "billing",
1162
- "payment",
1163
- "payments",
1164
- "invoice",
1165
- "checkout",
1166
- "wallet",
1167
- "support",
1168
- "help",
1169
- "helpdesk",
1170
- "service",
1171
- "official",
1172
- "update",
1173
- "updates",
1174
- "recover",
1175
- "recovery",
1176
- "reset",
1177
- "unlock",
1178
- "confirm",
1179
- "settings",
1180
- // high-value brand impersonation targets
1181
- "google",
1182
- "gmail",
1183
- "youtube",
1184
- "apple",
1185
- "icloud",
1186
- "itunes",
1187
- "microsoft",
1188
- "outlook",
1189
- "office365",
1190
- "windows",
1191
- "amazon",
1192
- "aws",
1193
- "paypal",
1194
- "venmo",
1195
- "stripe",
1196
- "visa",
1197
- "mastercard",
1198
- "amex",
1199
- "chase",
1200
- "wellsfargo",
1201
- "bankofamerica",
1202
- "citibank",
1203
- "hsbc",
1204
- "barclays",
1205
- "coinbase",
1206
- "binance",
1207
- "kraken",
1208
- "metamask",
1209
- "facebook",
1210
- "instagram",
1211
- "whatsapp",
1212
- "meta",
1213
- "twitter",
1214
- "netflix",
1215
- "spotify",
1216
- "dropbox",
1217
- "github",
1218
- "gitlab",
1219
- "slack",
1220
- "zoom",
1221
- "linkedin",
1222
- "tiktok",
1223
- "cloudflare",
1224
- "usps",
1225
- "ups",
1226
- "fedex",
1227
- "dhl",
1228
- "irs",
1229
- "gov"
1230
- ];
1231
- RESERVED = new Set(RESERVED_NAMES);
1232
- }
1233
- });
1234
-
1235
1097
  // ../../node_modules/.pnpm/zod@3.25.76/node_modules/zod/v3/helpers/util.js
1236
1098
  var util, objectUtil, ZodParsedType, getParsedType;
1237
1099
  var init_util = __esm({
@@ -5338,9 +5200,155 @@ var init_zod = __esm({
5338
5200
  }
5339
5201
  });
5340
5202
 
5203
+ // ../schema/src/query.ts
5204
+ var QueryBy, TypeQuery, QueryConfig;
5205
+ var init_query = __esm({
5206
+ "../schema/src/query.ts"() {
5207
+ "use strict";
5208
+ init_zod();
5209
+ init_universal();
5210
+ QueryBy = external_exports.object({
5211
+ filter: external_exports.array(external_exports.string().min(1)).min(1),
5212
+ /** Omitted means newest-first, which the appended rowid gives for free. */
5213
+ sort: external_exports.string().min(1).optional()
5214
+ }).strict();
5215
+ TypeQuery = external_exports.object({
5216
+ /** One entry per field you want to filter, range or sort on. */
5217
+ fields: external_exports.array(external_exports.string().min(1)).default([]),
5218
+ /** One entry per COMBINATION: filter+sort, or two filters at once. */
5219
+ by: external_exports.array(QueryBy).default([]),
5220
+ /** The fields full-text search looks in. */
5221
+ search: external_exports.array(external_exports.string().min(1)).default([])
5222
+ }).strict();
5223
+ QueryConfig = external_exports.record(TypeQuery);
5224
+ }
5225
+ });
5226
+
5227
+ // ../schema/src/names.ts
5228
+ var RESERVED_NAMES, RESERVED;
5229
+ var init_names = __esm({
5230
+ "../schema/src/names.ts"() {
5231
+ "use strict";
5232
+ RESERVED_NAMES = [
5233
+ // platform & infrastructure
5234
+ "drivethrough",
5235
+ "drivethru",
5236
+ "www",
5237
+ "mail",
5238
+ "smtp",
5239
+ "imap",
5240
+ "ftp",
5241
+ "ns1",
5242
+ "ns2",
5243
+ "api",
5244
+ "cdn",
5245
+ "static",
5246
+ "assets",
5247
+ "status",
5248
+ "docs",
5249
+ "blog",
5250
+ "dev",
5251
+ "app",
5252
+ // auth & account impersonation
5253
+ "login",
5254
+ "logout",
5255
+ "signin",
5256
+ "signup",
5257
+ "auth",
5258
+ "authorize",
5259
+ "oauth",
5260
+ "sso",
5261
+ "secure",
5262
+ "security",
5263
+ "verify",
5264
+ "verification",
5265
+ "activate",
5266
+ "session",
5267
+ "account",
5268
+ "accounts",
5269
+ "admin",
5270
+ "administrator",
5271
+ "root",
5272
+ "password",
5273
+ "passwords",
5274
+ "billing",
5275
+ "payment",
5276
+ "payments",
5277
+ "invoice",
5278
+ "checkout",
5279
+ "wallet",
5280
+ "support",
5281
+ "help",
5282
+ "helpdesk",
5283
+ "service",
5284
+ "official",
5285
+ "update",
5286
+ "updates",
5287
+ "recover",
5288
+ "recovery",
5289
+ "reset",
5290
+ "unlock",
5291
+ "confirm",
5292
+ "settings",
5293
+ // high-value brand impersonation targets
5294
+ "google",
5295
+ "gmail",
5296
+ "youtube",
5297
+ "apple",
5298
+ "icloud",
5299
+ "itunes",
5300
+ "microsoft",
5301
+ "outlook",
5302
+ "office365",
5303
+ "windows",
5304
+ "amazon",
5305
+ "aws",
5306
+ "paypal",
5307
+ "venmo",
5308
+ "stripe",
5309
+ "visa",
5310
+ "mastercard",
5311
+ "amex",
5312
+ "chase",
5313
+ "wellsfargo",
5314
+ "bankofamerica",
5315
+ "citibank",
5316
+ "hsbc",
5317
+ "barclays",
5318
+ "coinbase",
5319
+ "binance",
5320
+ "kraken",
5321
+ "metamask",
5322
+ "facebook",
5323
+ "instagram",
5324
+ "whatsapp",
5325
+ "meta",
5326
+ "twitter",
5327
+ "netflix",
5328
+ "spotify",
5329
+ "dropbox",
5330
+ "github",
5331
+ "gitlab",
5332
+ "slack",
5333
+ "zoom",
5334
+ "linkedin",
5335
+ "tiktok",
5336
+ "cloudflare",
5337
+ "usps",
5338
+ "ups",
5339
+ "fedex",
5340
+ "dhl",
5341
+ "irs",
5342
+ "gov"
5343
+ ];
5344
+ RESERVED = new Set(RESERVED_NAMES);
5345
+ }
5346
+ });
5347
+
5341
5348
  // ../schema/src/site-config.ts
5342
5349
  function parseSiteConfig(input) {
5343
- const result = SiteConfig.safeParse(input);
5350
+ const stripped = stripRemovedKeys(input);
5351
+ const result = SiteConfig.safeParse(stripped);
5344
5352
  if (!result.success) {
5345
5353
  const errors = result.error.issues.map((issue2) => {
5346
5354
  const where = issue2.path.length ? `at ${issue2.path.join(".")}` : "at the top level";
@@ -5350,11 +5358,21 @@ function parseSiteConfig(input) {
5350
5358
  }
5351
5359
  return { ok: true, config: result.data };
5352
5360
  }
5353
- var FieldSpec, RateLimit, TYPE_NAME, TypeConfig, IndexDecl, SiteConfig;
5361
+ function stripRemovedKeys(input) {
5362
+ if (typeof input !== "object" || input === null || Array.isArray(input)) return input;
5363
+ const record2 = input;
5364
+ const present = Object.keys(REMOVED_KEYS).filter((key) => key in record2);
5365
+ if (present.length === 0) return input;
5366
+ const copy = { ...record2 };
5367
+ for (const key of present) delete copy[key];
5368
+ return copy;
5369
+ }
5370
+ var FieldSpec, RateLimit, TYPE_NAME, TypeConfig, SiteConfig, REMOVED_KEYS;
5354
5371
  var init_site_config = __esm({
5355
5372
  "../schema/src/site-config.ts"() {
5356
5373
  "use strict";
5357
5374
  init_zod();
5375
+ init_query();
5358
5376
  FieldSpec = external_exports.enum(["string", "string?", "number", "number?", "boolean", "boolean?"]);
5359
5377
  RateLimit = external_exports.string().regex(/^\d+\/(minute|hour|day)$/, 'rate_limit must look like "5/hour"');
5360
5378
  TYPE_NAME = external_exports.string().regex(/^[a-z0-9](?:[a-z0-9_-]*[a-z0-9])?$/, "type names are lowercase letters, numbers, hyphens and underscores").max(64);
@@ -5380,10 +5398,6 @@ var init_site_config = __esm({
5380
5398
  parent_types: external_exports.array(external_exports.string().min(1)).optional(),
5381
5399
  rate_limit: RateLimit.optional()
5382
5400
  }).strict();
5383
- IndexDecl = external_exports.object({
5384
- type: external_exports.string().min(1),
5385
- path: external_exports.string().regex(/^\$(\.[A-Za-z0-9_]+)+$/, 'index paths are scalar JSON paths like "$.date"')
5386
- }).strict();
5387
5401
  SiteConfig = external_exports.object({
5388
5402
  /** Sitename: lowercase alphanumeric + hyphens, unique per user. */
5389
5403
  site: external_exports.string().regex(/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/, "site is lowercase letters, numbers and hyphens").optional(),
@@ -5394,10 +5408,18 @@ var init_site_config = __esm({
5394
5408
  // cannot be parameterised. Keeping them to this alphabet is what makes that
5395
5409
  // interpolation safe, rather than relying on escaping at each use.
5396
5410
  types: external_exports.record(TYPE_NAME, TypeConfig).default({}),
5397
- indexes: external_exports.array(IndexDecl).default([]),
5411
+ /**
5412
+ * What each type's pages will filter, sort and search on. One entry is one
5413
+ * index is one extra billed write per row of that type; an undeclared query
5414
+ * is refused rather than allowed to scan (query surface §1, §4).
5415
+ */
5416
+ query: QueryConfig.default({}),
5398
5417
  /** Pro (§3): additive custom-schema SQL applied to the site database. */
5399
5418
  schema: external_exports.string().max(2e4).optional()
5400
5419
  }).strict();
5420
+ REMOVED_KEYS = {
5421
+ indexes: 'The "indexes" key was replaced by "query" \u2014 see query.<type>.fields.'
5422
+ };
5401
5423
  }
5402
5424
  });
5403
5425
 
@@ -5413,6 +5435,7 @@ var init_src = __esm({
5413
5435
  "../schema/src/index.ts"() {
5414
5436
  "use strict";
5415
5437
  init_universal();
5438
+ init_query();
5416
5439
  init_names();
5417
5440
  init_site_config();
5418
5441
  init_bundle();
@@ -24343,59 +24366,81 @@ function registerTools(server, ctx) {
24343
24366
  "query_db",
24344
24367
  {
24345
24368
  title: "Query site database",
24346
- description: "Read the site's database as its owner (read-only \u2014 writes go through write_db): list the types present with row counts, list rows of a type, or fetch one row by id. Every row is {id, parent_id, type, json, created_at} \u2014 json is the document. Owner access skips visitor-side permission checks.",
24369
+ description: 'Read the site\'s database as its owner (read-only \u2014 writes go through write_db). Same query surface the site\'s own pages use, so what works here works there. Every row is {seq, type, parent_seq, json, created_at, updated_at}: `seq` is the row number and its permanent identity (never reused, even after a delete), `json` is the document. Newest-first is the default and always free. Filtering, sorting and search need a matching entry in the site\'s "query" block in drivethrough.json \u2014 an undeclared query is refused with the exact line to add. As the owner you get a bounded 5,000-row scan instead of a refusal, marked "partial": true, so you can look at data you have not modelled yet; a visitor page gets the refusal.',
24347
24370
  annotations: { readOnlyHint: true, openWorldHint: false },
24348
24371
  inputSchema: {
24349
24372
  site: external_exports.string().describe("Site name or id"),
24350
- action: external_exports.enum(["types", "list", "get"]).describe("types = row counts per type; list = rows of a type; get = one row by id"),
24373
+ action: external_exports.enum(["types", "list", "get"]).describe("types = row counts per type; list = rows of a type; get = one row by seq"),
24351
24374
  type: external_exports.string().optional().describe("Row type \u2014 required for list"),
24352
- id: external_exports.string().optional().describe("Row id \u2014 required for get"),
24353
- parent_id: external_exports.string().optional().describe("list: return only rows whose parent is this row id"),
24375
+ seq: external_exports.number().int().optional().describe("Row number \u2014 required for get"),
24376
+ filter: external_exports.record(external_exports.unknown()).optional().describe(
24377
+ 'list: exact matches, and ranges. Exact: {"genre": "thriller"}. Range: {"year": {"gte": 1990, "lte": 1999}} (also gt, lt). Each field must be listed in query.<type>.fields in drivethrough.json; filtering on two fields at once, or filtering while sorting by a third, additionally needs an entry in query.<type>.by. A range only works when sort names the same field.'
24378
+ ),
24379
+ sort: external_exports.string().optional().describe(
24380
+ 'list: a declared field, "-" for descending \u2014 e.g. "-year". Omit for newest-first, which needs no declaration. "created_at" and "-created_at" are always available and always free. Sorting by a field returns only rows that have it.'
24381
+ ),
24382
+ search: external_exports.string().optional().describe(
24383
+ "list: full-text search over the fields in query.<type>.search. Combines with exact `filter` values, but NOT with `sort` (results are ranked by relevance) and NOT with a range. Paged by `offset` up to 1000, never by `cursor`."
24384
+ ),
24385
+ parent_seq: external_exports.number().int().optional().describe("list: only rows whose parent is this row number"),
24354
24386
  limit: external_exports.number().int().optional().describe("list: max rows (default 100, cap 500)"),
24355
- offset: external_exports.number().int().optional().describe("list: rows to skip, for pagination")
24387
+ cursor: external_exports.string().optional().describe(
24388
+ "list: the next page. Take next_cursor from the previous response and pass it back unchanged. Flat cost at any depth \u2014 this is how you read past the first 1,000 rows."
24389
+ ),
24390
+ offset: external_exports.number().int().optional().describe(
24391
+ "list: rows to skip, for numbered pages. Capped at 1000 because offset walks past every row it skips; past that use cursor."
24392
+ ),
24393
+ count: external_exports.boolean().optional().describe(
24394
+ 'list: include a result count, counted up to 1,000 and then reported as {"count": 1000, "count_capped": true}. Costs an extra pass \u2014 ask for it only when you are showing the number.'
24395
+ )
24356
24396
  }
24357
24397
  },
24358
- async ({
24359
- site,
24360
- action,
24361
- type,
24362
- id,
24363
- parent_id,
24364
- limit,
24365
- offset
24366
- }) => {
24398
+ async (args) => {
24399
+ const { site, action, type, seq } = args;
24367
24400
  try {
24368
24401
  switch (action) {
24369
24402
  case "types":
24370
24403
  return ok(await ctx.api.request("GET", `${sitePath(site)}/data/types`));
24371
24404
  case "list": {
24372
- if (parent_id !== void 0 && parent_id === "") {
24405
+ if (!type) {
24373
24406
  throw new ToolApiError(422, {
24374
24407
  error: {
24375
- code: "parent_filter_unsupported",
24376
- message: "Filtering to rows WITHOUT a parent isn't supported \u2014 `parent_id` must be a row id.",
24377
- next: "List the type without parent_id and select the parent_id === null rows from the result."
24408
+ code: "missing_type",
24409
+ message: 'query_db action "list" needs `type`.',
24410
+ next: 'See what types exist with action "types".'
24378
24411
  }
24379
24412
  });
24380
24413
  }
24381
- const params = new URLSearchParams();
24382
- if (type) params.set("type", type);
24383
- if (parent_id !== void 0) params.set("parent", parent_id);
24384
- if (limit !== void 0) params.set("limit", String(limit));
24385
- if (offset !== void 0) params.set("offset", String(offset));
24414
+ const params = new URLSearchParams({ type });
24415
+ for (const [field, value] of Object.entries(args.filter ?? {})) {
24416
+ if (value !== null && typeof value === "object" && !Array.isArray(value)) {
24417
+ for (const [op, bound] of Object.entries(value)) {
24418
+ params.set(`filter.${field}.${op}`, String(bound));
24419
+ }
24420
+ } else {
24421
+ params.set(`filter.${field}`, String(value));
24422
+ }
24423
+ }
24424
+ if (args.sort) params.set("sort", args.sort);
24425
+ if (args.search) params.set("search", args.search);
24426
+ if (args.parent_seq !== void 0) params.set("parent", String(args.parent_seq));
24427
+ if (args.limit !== void 0) params.set("limit", String(args.limit));
24428
+ if (args.cursor) params.set("cursor", args.cursor);
24429
+ if (args.offset !== void 0) params.set("offset", String(args.offset));
24430
+ if (args.count) params.set("count", "1");
24386
24431
  return ok(await ctx.api.request("GET", `${sitePath(site)}/data?${params}`));
24387
24432
  }
24388
24433
  case "get": {
24389
- if (!id) {
24434
+ if (seq === void 0) {
24390
24435
  throw new ToolApiError(422, {
24391
24436
  error: {
24392
- code: "missing_id",
24393
- message: 'query_db action "get" needs `id`.',
24394
- next: 'Find row ids with action "list".'
24437
+ code: "missing_seq",
24438
+ message: 'query_db action "get" needs `seq` \u2014 the row number.',
24439
+ next: 'Row numbers come back with every row from action "list".'
24395
24440
  }
24396
24441
  });
24397
24442
  }
24398
- return ok(await ctx.api.request("GET", `${sitePath(site)}/data?id=${encodeURIComponent(id)}`));
24443
+ return ok(await ctx.api.request("GET", `${sitePath(site)}/data?seq=${encodeURIComponent(String(seq))}`));
24399
24444
  }
24400
24445
  }
24401
24446
  } catch (error2) {
@@ -24407,18 +24452,24 @@ function registerTools(server, ctx) {
24407
24452
  "write_db",
24408
24453
  {
24409
24454
  title: "Write site database",
24410
- description: "Write to the site's database as its owner: insert, update, or delete rows (reads go through query_db). To change a few fields of a row, `update` with `merge` \u2014 only the fields that change, no fetch needed. Reserve `json` (wholesale replacement) for rewriting the whole document. Writes are validated against the types declared in the site's drivethrough.json (unknown types and mismatched fields are rejected); pass validate: false only for a deliberate migration. Deleted and overwritten rows are gone for good \u2014 there is no version history for data, so read a row (query_db) before destructive rewrites you might need to undo. Daily write quotas apply.",
24455
+ description: "Write to the site's database as its owner: insert one row, import many, update, or delete (reads go through query_db). Rows are addressed by `seq`, the row number query_db returns. To change a few fields of a row, `update` with `merge` \u2014 only the fields that change, no fetch needed. Reserve `json` (wholesale replacement) for rewriting the whole document. To load a catalogue, use `rows` for a handful and `rows_url` for thousands \u2014 never loop insert, and never try to emit 100,000 rows yourself. Writes are validated against the types declared in the site's drivethrough.json (unknown types and mismatched fields are rejected, and a bulk import is checked in full BEFORE the first row is written, so it is all-or-nothing); pass validate: false only for a deliberate migration. Deleted and overwritten rows are gone for good \u2014 there is no version history for data, so read a row (query_db) before destructive rewrites you might need to undo. Deleting a row also deletes its children. Every write counts against the account's monthly write allowance, and an import larger than what remains is refused before anything is written.",
24411
24456
  annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
24412
24457
  inputSchema: {
24413
24458
  site: external_exports.string().describe("Site name or id"),
24414
24459
  action: external_exports.enum(["insert", "update", "delete"]).describe("What to do with the row"),
24415
24460
  type: external_exports.string().optional().describe("Row type \u2014 required for insert"),
24416
- id: external_exports.string().optional().describe("Row id \u2014 required for update and delete"),
24417
- parent_id: external_exports.string().nullable().optional().describe("Parent row id to set on insert/update (null clears it)"),
24418
- json: external_exports.record(external_exports.unknown()).optional().describe("The row's full document \u2014 required for insert; a wholesale replacement on update (prefer `merge`)"),
24461
+ seq: external_exports.number().int().optional().describe("Row number \u2014 required for update and delete"),
24462
+ parent_seq: external_exports.number().int().nullable().optional().describe("Parent row NUMBER to set on insert/update (null clears it). The parent must exist."),
24463
+ json: external_exports.record(external_exports.unknown()).optional().describe("The row's full document \u2014 for a single insert; a wholesale replacement on update (prefer `merge`)"),
24419
24464
  merge: external_exports.record(external_exports.unknown()).optional().describe(
24420
24465
  "update: change only these fields, merged into the row's existing document (RFC 7386: nested objects merge, null deletes a field). The cheap way to update \u2014 no fetch-modify-resend."
24421
24466
  ),
24467
+ rows: external_exports.array(external_exports.record(external_exports.unknown())).optional().describe(
24468
+ "insert: MANY documents at once, batched into multi-row statements server-side. Use this instead of looping insert \u2014 one row per call costs 50% more in billed writes and one HTTP round trip each. Answers {inserted, writes_charged, first_seq, last_seq} rather than the rows themselves. Up to a few thousand rows; past that use rows_url. One import carries at most 100,000 rows."
24469
+ ),
24470
+ rows_url: external_exports.string().optional().describe(
24471
+ "insert: a public https URL the platform fetches server-side, the same way deploy_site {path, source_url} fetches an image. NDJSON (one JSON object per line), CSV with a header row, or a JSON array. This is how a large catalogue gets loaded at all \u2014 you never hold the data. `created_at` is always the moment the platform wrote the row and is never read from the file; a row's own date is an ordinary declared field. Answers {inserted, writes_charged, first_seq, last_seq}. At most 100,000 rows per import \u2014 split a bigger file and import the parts."
24472
+ ),
24422
24473
  validate: external_exports.boolean().optional().describe("insert/update: set false to bypass declared-type validation \u2014 only for deliberate migrations")
24423
24474
  }
24424
24475
  },
@@ -24426,24 +24477,26 @@ function registerTools(server, ctx) {
24426
24477
  site,
24427
24478
  action,
24428
24479
  type,
24429
- id,
24430
- parent_id,
24480
+ seq,
24481
+ parent_seq,
24431
24482
  json,
24432
24483
  merge: merge2,
24484
+ rows,
24485
+ rows_url,
24433
24486
  validate: validate2
24434
24487
  }) => {
24435
24488
  try {
24436
- const needId = () => {
24437
- if (!id) {
24489
+ const needSeq = () => {
24490
+ if (seq === void 0) {
24438
24491
  throw new ToolApiError(422, {
24439
24492
  error: {
24440
- code: "missing_id",
24441
- message: `write_db action "${action}" needs \`id\`.`,
24442
- next: 'Find row ids with query_db action "list".'
24493
+ code: "missing_seq",
24494
+ message: `write_db action "${action}" needs \`seq\` \u2014 the row number.`,
24495
+ next: 'Row numbers come back with every row from query_db action "list".'
24443
24496
  }
24444
24497
  });
24445
24498
  }
24446
- return id;
24499
+ return seq;
24447
24500
  };
24448
24501
  switch (action) {
24449
24502
  case "insert": {
@@ -24451,8 +24504,8 @@ function registerTools(server, ctx) {
24451
24504
  throw new ToolApiError(422, {
24452
24505
  error: {
24453
24506
  code: "merge_not_for_insert",
24454
- message: "`merge` applies to update only \u2014 insert takes the row's full document in `json`.",
24455
- next: "Pass the document as `json`."
24507
+ message: "`merge` applies to update only \u2014 insert takes the row's document in `json`.",
24508
+ next: "Pass one document as `json`, many as `rows`, or a file as `rows_url`."
24456
24509
  }
24457
24510
  });
24458
24511
  }
@@ -24465,18 +24518,40 @@ function registerTools(server, ctx) {
24465
24518
  }
24466
24519
  });
24467
24520
  }
24521
+ if (rows !== void 0 || rows_url !== void 0) {
24522
+ if (json !== void 0) {
24523
+ throw new ToolApiError(422, {
24524
+ error: {
24525
+ code: "ambiguous_bulk_write",
24526
+ message: "Pass `json` for one row, or `rows`/`rows_url` for many \u2014 not both.",
24527
+ next: "Drop `json` and put that document in `rows` alongside the others."
24528
+ }
24529
+ });
24530
+ }
24531
+ return ok(
24532
+ await ctx.api.request("POST", `${sitePath(site)}/data`, {
24533
+ body: {
24534
+ type,
24535
+ ...parent_seq !== void 0 ? { parent_seq } : {},
24536
+ ...rows !== void 0 ? { rows } : {},
24537
+ ...rows_url !== void 0 ? { rows_url } : {},
24538
+ ...validate2 !== void 0 ? { validate: validate2 } : {}
24539
+ }
24540
+ })
24541
+ );
24542
+ }
24468
24543
  if (json === void 0) {
24469
24544
  throw new ToolApiError(422, {
24470
24545
  error: {
24471
24546
  code: "missing_json",
24472
24547
  message: 'write_db action "insert" needs `json` \u2014 the row\'s document.',
24473
- next: "Pass json: {} explicitly if the row is deliberately empty."
24548
+ next: "Pass json: {} explicitly if the row is deliberately empty, or use `rows` to insert many at once."
24474
24549
  }
24475
24550
  });
24476
24551
  }
24477
24552
  return ok(
24478
24553
  await ctx.api.request("POST", `${sitePath(site)}/data`, {
24479
- body: { type, parent_id, json, ...validate2 !== void 0 ? { validate: validate2 } : {} }
24554
+ body: { type, parent_seq, json, ...validate2 !== void 0 ? { validate: validate2 } : {} }
24480
24555
  })
24481
24556
  );
24482
24557
  }
@@ -24491,10 +24566,10 @@ function registerTools(server, ctx) {
24491
24566
  });
24492
24567
  }
24493
24568
  return ok(
24494
- await ctx.api.request("PATCH", `${sitePath(site)}/data/${encodeURIComponent(needId())}`, {
24569
+ await ctx.api.request("PATCH", `${sitePath(site)}/data/${encodeURIComponent(String(needSeq()))}`, {
24495
24570
  body: {
24496
24571
  ...type !== void 0 ? { type } : {},
24497
- ...parent_id !== void 0 ? { parent_id } : {},
24572
+ ...parent_seq !== void 0 ? { parent_seq } : {},
24498
24573
  ...json !== void 0 ? { json } : {},
24499
24574
  ...merge2 !== void 0 ? { merge: merge2 } : {},
24500
24575
  ...validate2 !== void 0 ? { validate: validate2 } : {}
@@ -24502,7 +24577,9 @@ function registerTools(server, ctx) {
24502
24577
  })
24503
24578
  );
24504
24579
  case "delete":
24505
- return ok(await ctx.api.request("DELETE", `${sitePath(site)}/data/${encodeURIComponent(needId())}`));
24580
+ return ok(
24581
+ await ctx.api.request("DELETE", `${sitePath(site)}/data/${encodeURIComponent(String(needSeq()))}`)
24582
+ );
24506
24583
  }
24507
24584
  } catch (error2) {
24508
24585
  return toolError(error2);
@@ -24692,6 +24769,7 @@ Work token-efficiently \u2014 generated output is your scarcest resource:
24692
24769
  - EDIT, don't resend: change existing files with edit_site (exact find/replace). Never re-emit a whole file to change a small part. Use deploy_site mode "patch" for new files or wholesale rewrites \u2014 unchanged files carry forward automatically.
24693
24770
  - REFERENCE, don't inline: never base64-encode a file \u2014 not by hand, not in code, and never as a data: URI in HTML. To ship an image or other binary asset, pick the route by where the bytes live: already at a public URL \u2192 deploy_site {path, source_url}, fetched server-side; a local file (the user's, or a chat attachment already in your runtime) \u2192 create_upload returns a link the user opens in their browser to drag files in; when they say done, call create_upload {session} for the upload_ids, then deploy_site {path, upload_id}. (Only when this server itself runs on the user's machine \u2014 the CLI \u2014 is there a \`directory\` parameter to deploy instead, binaries riding along; your own sandbox is not that machine, and files you generate there deploy fine as \`content\`.) To move or duplicate an ALREADY-DEPLOYED file, {path, from_path} copies it server-side \u2014 never re-upload unchanged bytes, and once an upload_id exists, reference it rather than inlining the same bytes again. Inline base64 content is capped at 1 MB and exists only for tiny pasted assets like a favicon.
24694
24771
  - MERGE, don't replace: to change a few fields of a database row, write_db update with \`merge\` (null deletes a field). No need to fetch the row first.
24772
+ - DECLARE queries, don't scan: a page that filters, sorts or searches its data needs a \`query\` block in drivethrough.json. An undeclared query is REFUSED at request time with the exact line to add \u2014 follow it rather than working around it. Newest-first needs no declaration and is always free.
24695
24773
  - UNDO by version, never by inverse edits: every deploy is a numbered version the platform keeps. When the user wants a change undone, call revert_site \u2014 it restores the exact bytes of any prior version in one call and proves it. Reconstructing old content from memory with edit_site is how sites end up in a state that is neither old nor new.
24696
24774
  - LOOK at what shipped: you have no browser, but preview_site returns a real rendered screenshot of any version \u2014 your only way to see visual results. Use it after layout/style/animation work, or to eyeball an old version before reverting.
24697
24775
  - DON'T overwrite unseen work: when you already know the deployed version (an earlier get_site or deploy in THIS conversation), pass it to deploy_site as \`base_version\` \u2014 if the site moved past it (another session deployed), the deploy is refused with a summary of what changed instead of clobbering it. Never make an extra call just to learn the version; omit it instead. edit_site guards itself. On a \`remote_changes\` refusal, read what changed (get_site {history: true}), fold it in, then redeploy with the current version.
@@ -24713,6 +24791,47 @@ RECIPE \u2014 public form that saves to the site's database (RSVPs, leads, guest
24713
24791
  5. Anonymous visitors only reach a public site (set_privacy) or a shared one \u2014 on a private site the form works for invited viewers.
24714
24792
  6. Verify \u2014 you have no browser, so the deploy response is your feedback loop: it returns \`warnings\` when an anonymously-writable type has no Turnstile widget on any page, and when a page fetches a type its visitors aren't allowed to read (the fetch 403s inside THEIR browser; the page looks perfect to you because you're the owner). Never report success while a warning stands. Then have the user submit once and query_db {action: "list", type: "lead"} to show the row landed. (Local dev accepts any turnstile_token \u2014 the test secret always passes \u2014 so there a scripted POST to /api/data proves the whole path.)
24715
24793
 
24794
+ RECIPE \u2014 a searchable, filterable list (catalogue, directory, archive)
24795
+ 1. Declare the type and its fields in drivethrough.json.
24796
+ 2. Declare the queries the page will make:
24797
+ "query": { "movie": { "fields": ["genre", "year"],
24798
+ "by": [{"filter": ["genre"], "sort": "year"}],
24799
+ "search": ["title", "director"] } }
24800
+ - "fields": one entry per field you filter, range or sort on.
24801
+ - "by": one entry per COMBINATION \u2014 filtering on one field while sorting by
24802
+ another, or filtering on two at once. Omit "sort" for newest-first.
24803
+ - "search": the fields full-text search looks in.
24804
+ One entry = one index = one extra billed write per row of that type. Declare
24805
+ only what a page actually uses; newest-first needs no declaration. Every
24806
+ field named here must also exist in that type's "fields", or the deploy fails.
24807
+ 3. Deploy. The response carries \`query_plan\`: the indexes built, the cost per
24808
+ insert in billed writes, and \u2014 when a declaration is added to a type that
24809
+ already has rows \u2014 what re-indexing them will cost. Turning search on for
24810
+ existing rows runs in the BACKGROUND: searches work immediately and return
24811
+ \`"search_status": "building"\` until it finishes. It never blocks the deploy.
24812
+ 4. Load the data:
24813
+ write_db {action: "insert", type: "movie", rows: [ ... ]} a handful
24814
+ write_db {action: "insert", type: "movie", rows_url: "https://\u2026/movies.ndjson"} thousands
24815
+ Never loop single inserts \u2014 it costs 50% more and one round trip per row.
24816
+ An import bigger than the month's remaining write allowance is refused
24817
+ BEFORE anything is written, so a catalogue is never half-loaded.
24818
+ 5. Fetch from the page, same origin:
24819
+ /api/data?type=movie&filter.genre=thriller&sort=-year&limit=50
24820
+ /api/data?type=movie&filter.year.gte=1990&filter.year.lte=1999&sort=-year
24821
+ /api/data?type=movie&search=crimson
24822
+ The response is {items, next_cursor}. Pass next_cursor back as &cursor=\u2026 for
24823
+ the next page \u2014 flat cost at any depth. &offset= works too but is capped at
24824
+ 1000. &count=1 adds a count, capped at 1,000 with "count_capped": true.
24825
+ Each item is {seq, type, parent_seq, json, created_at, updated_at}.
24826
+ 6. What is REFUSED, so you don't guess: filtering or sorting on an undeclared
24827
+ field; a combination with no "by" entry; a range not sorted by that same
24828
+ field; search together with sort= or with a range; a cursor from a different
24829
+ query; offset past 1000. Every refusal names the file, the key and the value
24830
+ to add \u2014 follow it.
24831
+ 7. Verify: you have no browser, so read the deploy \`warnings\`. They name a page
24832
+ whose query the config doesn't declare (it will render empty for visitors),
24833
+ and a declaration no page uses (pure cost on every insert).
24834
+
24716
24835
  Custom domains (paid plans): manage_domain attaches a domain the user already owns. After attaching, check_domain {action: "connect"} looks up one-click setup at their registrar (relay the URL when offered, else the exact DNS records from attach), and check_domain {action: "status"} polls until "active" \u2014 DNS seen, then TLS issued.`;
24717
24836
  }
24718
24837
  });
@@ -26815,11 +26934,11 @@ function isJsonMode(args) {
26815
26934
  function isInteractive(args) {
26816
26935
  return !isJsonMode(args) && !args.yes && process.stdout.isTTY === true && process.env.CI !== "true";
26817
26936
  }
26818
- function emit(args, data, human) {
26937
+ function emit(args, data, human2) {
26819
26938
  if (isJsonMode(args)) {
26820
26939
  process.stdout.write(JSON.stringify({ ok: true, ...data }, null, 2) + "\n");
26821
26940
  } else {
26822
- human();
26941
+ human2();
26823
26942
  }
26824
26943
  }
26825
26944
  function fail(args, error2) {
@@ -27387,9 +27506,14 @@ var listCommand = defineCommand({
27387
27506
  args: {
27388
27507
  site: { type: "positional", description: "Site name", required: true },
27389
27508
  type: { type: "positional", description: "Row type", required: true },
27390
- parent: { type: "string", description: "Only rows under this parent id" },
27509
+ parent: { type: "string", description: "Only rows under this parent row number" },
27510
+ filter: { type: "string", description: "Declared exact match, e.g. --filter genre=thriller (repeatable)" },
27511
+ sort: { type: "string", description: 'A declared field; "-" for descending, e.g. -year' },
27512
+ search: { type: "string", description: "Full-text search over the declared search fields" },
27391
27513
  limit: { type: "string", description: "Max rows (default 100, cap 500)" },
27392
- offset: { type: "string", description: "Rows to skip (pagination)" },
27514
+ cursor: { type: "string", description: "next_cursor from the previous page" },
27515
+ offset: { type: "string", description: "Rows to skip (numbered pages, capped at 1000)" },
27516
+ count: { type: "boolean", description: "Include a result count (capped at 1,000)" },
27393
27517
  json: { type: "boolean", description: "Machine-readable output" }
27394
27518
  },
27395
27519
  async run({ args }) {
@@ -27397,8 +27521,16 @@ var listCommand = defineCommand({
27397
27521
  try {
27398
27522
  const params = new URLSearchParams({ type: args.type });
27399
27523
  if (args.parent) params.set("parent", args.parent);
27524
+ for (const raw of [].concat(args.filter ?? [])) {
27525
+ const eq = raw.indexOf("=");
27526
+ if (eq > 0) params.set(`filter.${raw.slice(0, eq)}`, raw.slice(eq + 1));
27527
+ }
27528
+ if (args.sort) params.set("sort", args.sort);
27529
+ if (args.search) params.set("search", args.search);
27400
27530
  if (args.limit) params.set("limit", args.limit);
27531
+ if (args.cursor) params.set("cursor", args.cursor);
27401
27532
  if (args.offset) params.set("offset", args.offset);
27533
+ if (args.count) params.set("count", "1");
27402
27534
  const res = await api("GET", `/v1/sites/${args.site}/data?${params}`);
27403
27535
  emit(jsonArgs, { ...res }, () => {
27404
27536
  if (res.items.length === 0) {
@@ -27406,8 +27538,16 @@ var listCommand = defineCommand({
27406
27538
  return;
27407
27539
  }
27408
27540
  for (const row of res.items) {
27409
- M2.message(` ${row.id} ${JSON.stringify(row.json)}`);
27541
+ M2.message(` ${row.seq} ${JSON.stringify(row.json)}`);
27542
+ }
27543
+ if (res.count !== void 0) {
27544
+ M2.message(` ${res.count}${res.count_capped ? "+" : ""} total`);
27410
27545
  }
27546
+ if (res.search_status === "building") {
27547
+ M2.warn("Search is still indexing existing rows \u2014 these are the ones indexed so far.");
27548
+ }
27549
+ if (res.partial) M2.warn(`Partial result \u2014 this query isn't declared. ${res.declare ?? ""}`);
27550
+ if (res.next_cursor) M2.message(` next: --cursor ${res.next_cursor}`);
27411
27551
  });
27412
27552
  } catch (error2) {
27413
27553
  fail(jsonArgs, error2);
@@ -27415,10 +27555,10 @@ var listCommand = defineCommand({
27415
27555
  }
27416
27556
  });
27417
27557
  var getCommand = defineCommand({
27418
- meta: { name: "get", description: "Fetch one row by id" },
27558
+ meta: { name: "get", description: "Fetch one row by its row number" },
27419
27559
  args: {
27420
27560
  site: { type: "positional", description: "Site name", required: true },
27421
- id: { type: "positional", description: "Row id", required: true },
27561
+ seq: { type: "positional", description: "Row number (seq)", required: true },
27422
27562
  json: { type: "boolean", description: "Machine-readable output" }
27423
27563
  },
27424
27564
  async run({ args }) {
@@ -27426,7 +27566,7 @@ var getCommand = defineCommand({
27426
27566
  try {
27427
27567
  const res = await api(
27428
27568
  "GET",
27429
- `/v1/sites/${args.site}/data?id=${encodeURIComponent(args.id)}`
27569
+ `/v1/sites/${args.site}/data?seq=${encodeURIComponent(args.seq)}`
27430
27570
  );
27431
27571
  emit(jsonArgs, { item: res.items[0] }, () => {
27432
27572
  M2.message(JSON.stringify(res.items[0], null, 2));
@@ -27675,27 +27815,44 @@ var collaboratorsCommand = defineCommand({
27675
27815
 
27676
27816
  // src/commands/usage.ts
27677
27817
  init_api();
27818
+ function human(value, unit) {
27819
+ if (unit === "bytes") {
27820
+ if (value >= 1024 ** 3) return `${(value / 1024 ** 3).toFixed(2)} GB`;
27821
+ return `${(value / 1024 ** 2).toFixed(1)} MB`;
27822
+ }
27823
+ return value.toLocaleString("en-US");
27824
+ }
27678
27825
  var usageCommand = defineCommand({
27679
- meta: { name: "usage", description: "Show a site's storage, writes, and index suggestions" },
27826
+ meta: { name: "usage", description: "Show this month's usage against your plan's limits" },
27680
27827
  args: {
27681
- site: { type: "positional", description: "Site name", required: true },
27828
+ site: { type: "positional", description: "Site name (omit for the account rollup)", required: false },
27682
27829
  json: { type: "boolean", description: "Machine-readable output" }
27683
27830
  },
27684
27831
  async run({ args }) {
27685
27832
  const jsonArgs = { json: args.json };
27686
27833
  try {
27834
+ if (!args.site) {
27835
+ const usage2 = await api("GET", `/v1/usage`);
27836
+ emit(jsonArgs, { ...usage2 }, () => {
27837
+ M2.message(`Plan: ${usage2.plan} \u2014 resets ${new Date(usage2.period.resets_at).toDateString()}`);
27838
+ for (const meter of usage2.meters) {
27839
+ const line = `${meter.label.padEnd(18)} ${human(meter.used, meter.unit)} / ${human(meter.limit, meter.unit)} (${meter.percent}%)${meter.lagged ? " ~" : ""}`;
27840
+ if (meter.state === "over") M2.error(line);
27841
+ else if (meter.state === "warn") M2.warn(line);
27842
+ else M2.message(line);
27843
+ }
27844
+ });
27845
+ return;
27846
+ }
27687
27847
  const usage = await api("GET", `/v1/sites/${args.site}/usage`);
27688
27848
  emit(jsonArgs, { ...usage }, () => {
27689
- M2.message(`Storage: ${(usage.storage_bytes / 1024 / 1024).toFixed(2)} MB`);
27849
+ M2.message(`Storage: ${(usage.storage_bytes / 1024 / 1024).toFixed(2)} MB`);
27690
27850
  M2.message(`Writes today: ${usage.d1_writes_today}`);
27691
- M2.message(`Rows read today: ${usage.rows_read_today}`);
27692
- if (usage.index_suggestions.length) {
27693
- M2.warn("Index suggestions (add to drivethrough.json \u2192 indexes):");
27694
- for (const s2 of usage.index_suggestions) {
27695
- M2.message(` { "type": "${s2.type}", "path": "${s2.path}" } \u2014 ${s2.reason}`);
27696
- }
27697
- } else {
27698
- M2.success("No index suggestions \u2014 your queries are index-friendly.");
27851
+ M2.message(
27852
+ `Database: ${usage.db_size_bytes === null ? "unknown" : `${(usage.db_size_bytes / 1024 / 1024).toFixed(2)} MB`} of ${(usage.db_cap_bytes / 1024 / 1024).toFixed(0)} MB`
27853
+ );
27854
+ if (usage.query_indexes.length) {
27855
+ M2.message(`Query indexes: ${usage.query_indexes.join(", ")}`);
27699
27856
  }
27700
27857
  });
27701
27858
  } catch (error2) {
@@ -27785,15 +27942,115 @@ Declare data types the site collects. The platform owns the write path.
27785
27942
  \`"read": "public"\` explicitly to publish submissions, e.g. a public guestbook.
27786
27943
  - \`unique_per: "session"\`: the row edits itself (an RSVP updates, never duplicates).
27787
27944
  - \`notify: "owner-email"\`: the owner gets an email on each entry (leads/RSVPs).
27788
- - \`indexes\`: [{ "type": "event", "path": "$.date" }] for larger datasets.
27789
27945
 
27790
27946
  Deploying a drivethrough.json with types auto-provisions the site's database.
27791
27947
 
27948
+ ## Filtering, sorting and searching \u2014 the \`query\` block
27949
+
27950
+ **A query is either declared, or it is refused.** There is no third state where
27951
+ a visitor's dropdown quietly scans the whole table, because a page whose cost
27952
+ grows with the table is a page that gets slower as the site gets more popular.
27953
+
27954
+ \`\`\`json
27955
+ {
27956
+ "types": {
27957
+ "movie": {
27958
+ "write": "owner", "read": "public",
27959
+ "fields": { "title": "string", "genre": "string", "year": "number", "director": "string" }
27960
+ }
27961
+ },
27962
+ "query": {
27963
+ "movie": {
27964
+ "fields": ["genre", "year"],
27965
+ "by": [{ "filter": ["genre"], "sort": "year" }],
27966
+ "search": ["title", "director"]
27967
+ }
27968
+ }
27969
+ }
27970
+ \`\`\`
27971
+
27972
+ Three keys, and they are the whole model:
27973
+ - \`fields\` \u2014 one entry per field you filter, range or sort on.
27974
+ - \`by\` \u2014 one entry per COMBINATION: filtering on one field while sorting by
27975
+ another, or filtering on two at once. Omit \`sort\` for newest-first.
27976
+ - \`search\` \u2014 the fields full-text search looks in.
27977
+
27978
+ One entry = one index = one extra billed write on every row of that type, so
27979
+ declare only what a page actually uses. Every field named under \`query\` must
27980
+ also exist in that type's \`fields\`, or the deploy fails. An insert may cost at
27981
+ most 8 billed writes per type (2 base + up to 5 shapes + search).
27982
+
27983
+ **Newest-first needs no declaration and is always free** \u2014 \`sort=-created_at\`
27984
+ (the default) and \`sort=created_at\` are served by an index every site already
27985
+ has. A document's own date (a post's publish date, an event's start) is an
27986
+ ordinary field you declare like any other; it is content, not bookkeeping.
27987
+
27988
+ The deploy response carries \`query_plan\`: the indexes built, the cost per
27989
+ insert, and \u2014 when you add a declaration to a type that already has rows \u2014 what
27990
+ re-indexing them will cost, before it happens.
27991
+
27792
27992
  ## Client-side data access
27793
27993
  Static pages call the same-origin Data API \u2014 no SDK required:
27794
- - \`POST /api/data\` with JSON \`{type, json: {\u2026fields}, turnstile_token?}\`
27994
+ - \`POST /api/data\` with JSON \`{type, json: {\u2026fields}, parent_seq?, turnstile_token?}\`
27795
27995
  inserts (turnstile_token only for anonymous writes \u2014 wiring above).
27796
- - \`GET /api/data?type=X\` \u2192 \`{items}\` lists the rows the caller may read.
27996
+ - \`GET /api/data?type=X\` \u2192 \`{items, next_cursor}\` lists the rows the caller
27997
+ may read. Each item is \`{seq, type, parent_seq, json, created_at, updated_at}\`.
27998
+ \`seq\` is the row number and its permanent identity \u2014 never reused, so a URL
27999
+ like \`/movie/402\` can never come back pointing at a different row.
28000
+
28001
+ Everything a page can ask for, in full:
28002
+ \`\`\`
28003
+ GET /api/data
28004
+ ?type=movie required \u2014 which kind of row
28005
+ &filter.genre=thriller exact match on a declared field
28006
+ &filter.year.gte=1990 range: .gte .lte .gt .lt
28007
+ &search=crimson midnight full-text over declared search fields
28008
+ &sort=-year declared field; "-" means descending
28009
+ &limit=50 1\u2013500, default 100
28010
+ &cursor=eyJ2IjoxLCJzIjo\u2026 next page (next_cursor from the last response)
28011
+ &offset=200 0\u20131000 only; past that, use cursor
28012
+ &count=1 include a count, capped at 1,000
28013
+ &parent=41 only rows whose parent is this row
28014
+ \`\`\`
28015
+
28016
+ **Pagination:** pass \`next_cursor\` back as \`&cursor=\u2026\` \u2014 flat cost at any
28017
+ depth, which is how a page reads past its first 1,000 rows. \`offset\` is a
28018
+ convenience for numbered pages ("page 3") and is capped at 1000, because it
28019
+ walks past every row it skips.
28020
+
28021
+ **Search** is ranked by relevance, so it does NOT combine with \`sort=\` or with
28022
+ a range, and it pages by \`offset\` (up to 1000) rather than by cursor. It does
28023
+ combine with exact \`filter\` values. While a newly-declared search is still
28024
+ indexing existing rows, responses carry \`"search_status": "building"\` \u2014 show
28025
+ the results you got, not an empty state.
28026
+
28027
+ **What gets refused, so you don't guess:** filtering or sorting on a field not
28028
+ in \`query.<type>.fields\`; a combination with no \`by\` entry; a range not sorted
28029
+ by that same field; \`search\` with \`sort\` or with a range; a cursor from a
28030
+ different query; \`offset\` past 1000. Every refusal is
28031
+ \`{error: {code, message, next}}\` and \`next\` names the file, the key and the
28032
+ value to add \u2014 follow it rather than working around it.
28033
+
28034
+ ## Loading a lot of data
28035
+ Never loop single inserts: one row per call costs 50% more in billed writes and
28036
+ one HTTP round trip each.
28037
+ - A handful to a few thousand rows: \`write_db {action: "insert", type, rows: [...]}\`
28038
+ \u2014 batched into ~250-row statements server-side.
28039
+ - Thousands and up: \`write_db {action: "insert", type, rows_url: "https://\u2026/movies.ndjson"}\`
28040
+ \u2014 the platform fetches and parses it server-side (NDJSON, CSV with a header
28041
+ row, or a JSON array), so you never have to hold or emit the data. This is the
28042
+ only way a large catalogue gets loaded at all.
28043
+
28044
+ \`created_at\` is always the moment the platform wrote the row and is never read
28045
+ from an import \u2014 that is what keeps free newest-first ordering honest. A row's
28046
+ own date is an ordinary declared field.
28047
+
28048
+ Writes count against the account's monthly allowance (5,000 / 50,000 / 250,000
28049
+ on Free / Personal / Pro), owner writes included, and so do index rebuilds and
28050
+ search backfills. An import bigger than what remains is refused **before the
28051
+ first row is written** \u2014 the error names all three ways out: import fewer rows,
28052
+ wait for the reset date it gives you, or upgrade. A half-loaded catalogue is a
28053
+ bug, not a degraded success, so this never partially succeeds.
27797
28054
  A typed helper ships in the starter template and keeps it legible:
27798
28055
  \`\`\`ts
27799
28056
  import { data } from "./lib/data"; // ships in the starter template
@@ -27814,13 +28071,17 @@ continue in a later session:
27814
28071
  - \`drivethrough files <site> --json\` \u2014 the deployed version's manifest and
27815
28072
  file contents (or one file: \`drivethrough files <site> /index.html\`).
27816
28073
  - \`drivethrough data types <site>\` / \`data list <site> <type>\` /
27817
- \`data get <site> <id>\` \u2014 inspect the site's database rows.
28074
+ \`data get <site> <seq>\` \u2014 inspect the site's database rows.
27818
28075
  - MCP: \`get_site\` and \`query_db\` do the same. Reads and writes are separate
27819
- tools: \`query_db\` lists types, lists rows, and fetches one row; \`write_db\`
27820
- inserts, updates, and deletes. Writes are validated against the site's
27821
- declared types \u2014 pass \`validate: false\` only for deliberate migrations \u2014
27822
- and update with \`merge\` to change a few fields without resending the
27823
- document.
28076
+ tools: \`query_db\` lists types, lists rows (with the same \`filter\`, \`sort\`,
28077
+ \`search\`, \`cursor\` and \`count\` the pages use), and fetches one row by
28078
+ \`seq\`; \`write_db\` inserts, imports, updates, and deletes. Writes are
28079
+ validated against the site's declared types \u2014 pass \`validate: false\` only for
28080
+ deliberate migrations \u2014 and update with \`merge\` to change a few fields
28081
+ without resending the document. As the owner, a query the config doesn't
28082
+ declare gives you a bounded 5,000-row scan marked \`"partial": true\` instead
28083
+ of a refusal, so you can explore data you haven't modelled \u2014 but a visitor
28084
+ page gets the refusal, so declare it before shipping the page.
27824
28085
  - Locate before you read: \`get_site\` with \`find\` searches the deployed text
27825
28086
  files and returns byte-exact matching lines; \`manifest_only\` lists files
27826
28087
  without contents. Read whole files only when you truly need them.
@@ -27919,12 +28180,20 @@ This project deploys to Drivethrough. Use the \`drivethrough\` CLI (alias \`dt\`
27919
28180
  so a deploy over unseen remote changes is refused (409 remote_changes) \u2014
27920
28181
  sync down and redeploy; \`--force\` overwrites (revert can restore).
27921
28182
  - Sites are private by default; share with \`drivethrough share <site>\`.
27922
- - Declare data behavior in \`drivethrough.json\` (types, fields, write policy).
28183
+ - Declare data behavior in \`drivethrough.json\`: \`types\` (fields, write/read
28184
+ policy) and \`query\` (what pages filter, sort and search on). A query the
28185
+ config doesn't declare is REFUSED at request time with the exact line to add;
28186
+ newest-first needs no declaration. One query entry = one index = one extra
28187
+ billed write per row.
28188
+ - Load data with \`write_db {rows: [...]}\` or \`{rows_url: "https://\u2026"}\` \u2014 never
28189
+ a loop of single inserts. An import over the month's remaining write allowance
28190
+ is refused before anything is written.
27923
28191
  - Read back what's live: \`drivethrough files <site> --json\` (deployed bundle)
27924
- and \`drivethrough data types|list|get\` (database rows). Inspection only \u2014
28192
+ and \`drivethrough data types|list|get\` (database rows, addressed by \`seq\`). Inspection only \u2014
27925
28193
  this repo wins AFTER sync-down; never overwrite repo \`src/\` with read-back
27926
28194
  content (the deployed bundle is built output). Sync, edit here, rebuild,
27927
28195
  redeploy.
28196
+ - Check this month's usage against every limit: \`drivethrough usage --json\`.
27928
28197
  - Every command supports \`--json\` and non-interactive mode.
27929
28198
  - Full guide: run \`drivethrough agent-setup --skill\` for SKILL.md.
27930
28199
  `;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "drivethrough-cli",
3
- "version": "0.1.12",
3
+ "version": "0.1.14",
4
4
  "description": "Agent-first hosting CLI. Say what you want. Stay in the car.",
5
5
  "type": "module",
6
6
  "license": "MIT",