drivethrough-cli 0.1.11 → 0.1.13
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/main.mjs +500 -228
- package/package.json +1 -1
package/dist/main.mjs
CHANGED
|
@@ -1065,157 +1065,35 @@ var init_universal = __esm({
|
|
|
1065
1065
|
"use strict";
|
|
1066
1066
|
UNIVERSAL_SCHEMA_STATEMENTS = [
|
|
1067
1067
|
`CREATE TABLE IF NOT EXISTS data (
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
json TEXT,
|
|
1072
|
-
|
|
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
|
-
|
|
1075
|
-
//
|
|
1076
|
-
//
|
|
1077
|
-
//
|
|
1078
|
-
// `
|
|
1079
|
-
//
|
|
1080
|
-
|
|
1081
|
-
//
|
|
1082
|
-
//
|
|
1083
|
-
//
|
|
1084
|
-
|
|
1085
|
-
//
|
|
1086
|
-
//
|
|
1087
|
-
//
|
|
1088
|
-
|
|
1089
|
-
// and silently keep it. Renaming makes both statements true no-ops once the
|
|
1090
|
-
// upgrade has happened, so init() stays cheap to re-run.
|
|
1091
|
-
`DROP INDEX IF EXISTS idx_parent`,
|
|
1092
|
-
`CREATE INDEX IF NOT EXISTS idx_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`
|
|
1093
1092
|
];
|
|
1094
1093
|
UNIVERSAL_SCHEMA_SQL = UNIVERSAL_SCHEMA_STATEMENTS.join(";\n") + ";";
|
|
1095
1094
|
}
|
|
1096
1095
|
});
|
|
1097
1096
|
|
|
1098
|
-
// ../schema/src/names.ts
|
|
1099
|
-
var RESERVED_NAMES, RESERVED;
|
|
1100
|
-
var init_names = __esm({
|
|
1101
|
-
"../schema/src/names.ts"() {
|
|
1102
|
-
"use strict";
|
|
1103
|
-
RESERVED_NAMES = [
|
|
1104
|
-
// platform & infrastructure
|
|
1105
|
-
"drivethrough",
|
|
1106
|
-
"drivethru",
|
|
1107
|
-
"www",
|
|
1108
|
-
"mail",
|
|
1109
|
-
"smtp",
|
|
1110
|
-
"imap",
|
|
1111
|
-
"ftp",
|
|
1112
|
-
"ns1",
|
|
1113
|
-
"ns2",
|
|
1114
|
-
"api",
|
|
1115
|
-
"cdn",
|
|
1116
|
-
"static",
|
|
1117
|
-
"assets",
|
|
1118
|
-
"status",
|
|
1119
|
-
"docs",
|
|
1120
|
-
"blog",
|
|
1121
|
-
"dev",
|
|
1122
|
-
"app",
|
|
1123
|
-
// auth & account impersonation
|
|
1124
|
-
"login",
|
|
1125
|
-
"logout",
|
|
1126
|
-
"signin",
|
|
1127
|
-
"signup",
|
|
1128
|
-
"auth",
|
|
1129
|
-
"authorize",
|
|
1130
|
-
"oauth",
|
|
1131
|
-
"sso",
|
|
1132
|
-
"secure",
|
|
1133
|
-
"security",
|
|
1134
|
-
"verify",
|
|
1135
|
-
"verification",
|
|
1136
|
-
"activate",
|
|
1137
|
-
"session",
|
|
1138
|
-
"account",
|
|
1139
|
-
"accounts",
|
|
1140
|
-
"admin",
|
|
1141
|
-
"administrator",
|
|
1142
|
-
"root",
|
|
1143
|
-
"password",
|
|
1144
|
-
"passwords",
|
|
1145
|
-
"billing",
|
|
1146
|
-
"payment",
|
|
1147
|
-
"payments",
|
|
1148
|
-
"invoice",
|
|
1149
|
-
"checkout",
|
|
1150
|
-
"wallet",
|
|
1151
|
-
"support",
|
|
1152
|
-
"help",
|
|
1153
|
-
"helpdesk",
|
|
1154
|
-
"service",
|
|
1155
|
-
"official",
|
|
1156
|
-
"update",
|
|
1157
|
-
"updates",
|
|
1158
|
-
"recover",
|
|
1159
|
-
"recovery",
|
|
1160
|
-
"reset",
|
|
1161
|
-
"unlock",
|
|
1162
|
-
"confirm",
|
|
1163
|
-
"settings",
|
|
1164
|
-
// high-value brand impersonation targets
|
|
1165
|
-
"google",
|
|
1166
|
-
"gmail",
|
|
1167
|
-
"youtube",
|
|
1168
|
-
"apple",
|
|
1169
|
-
"icloud",
|
|
1170
|
-
"itunes",
|
|
1171
|
-
"microsoft",
|
|
1172
|
-
"outlook",
|
|
1173
|
-
"office365",
|
|
1174
|
-
"windows",
|
|
1175
|
-
"amazon",
|
|
1176
|
-
"aws",
|
|
1177
|
-
"paypal",
|
|
1178
|
-
"venmo",
|
|
1179
|
-
"stripe",
|
|
1180
|
-
"visa",
|
|
1181
|
-
"mastercard",
|
|
1182
|
-
"amex",
|
|
1183
|
-
"chase",
|
|
1184
|
-
"wellsfargo",
|
|
1185
|
-
"bankofamerica",
|
|
1186
|
-
"citibank",
|
|
1187
|
-
"hsbc",
|
|
1188
|
-
"barclays",
|
|
1189
|
-
"coinbase",
|
|
1190
|
-
"binance",
|
|
1191
|
-
"kraken",
|
|
1192
|
-
"metamask",
|
|
1193
|
-
"facebook",
|
|
1194
|
-
"instagram",
|
|
1195
|
-
"whatsapp",
|
|
1196
|
-
"meta",
|
|
1197
|
-
"twitter",
|
|
1198
|
-
"netflix",
|
|
1199
|
-
"spotify",
|
|
1200
|
-
"dropbox",
|
|
1201
|
-
"github",
|
|
1202
|
-
"gitlab",
|
|
1203
|
-
"slack",
|
|
1204
|
-
"zoom",
|
|
1205
|
-
"linkedin",
|
|
1206
|
-
"tiktok",
|
|
1207
|
-
"cloudflare",
|
|
1208
|
-
"usps",
|
|
1209
|
-
"ups",
|
|
1210
|
-
"fedex",
|
|
1211
|
-
"dhl",
|
|
1212
|
-
"irs",
|
|
1213
|
-
"gov"
|
|
1214
|
-
];
|
|
1215
|
-
RESERVED = new Set(RESERVED_NAMES);
|
|
1216
|
-
}
|
|
1217
|
-
});
|
|
1218
|
-
|
|
1219
1097
|
// ../../node_modules/.pnpm/zod@3.25.76/node_modules/zod/v3/helpers/util.js
|
|
1220
1098
|
var util, objectUtil, ZodParsedType, getParsedType;
|
|
1221
1099
|
var init_util = __esm({
|
|
@@ -5322,6 +5200,151 @@ var init_zod = __esm({
|
|
|
5322
5200
|
}
|
|
5323
5201
|
});
|
|
5324
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
|
+
|
|
5325
5348
|
// ../schema/src/site-config.ts
|
|
5326
5349
|
function parseSiteConfig(input) {
|
|
5327
5350
|
const result = SiteConfig.safeParse(input);
|
|
@@ -5334,11 +5357,12 @@ function parseSiteConfig(input) {
|
|
|
5334
5357
|
}
|
|
5335
5358
|
return { ok: true, config: result.data };
|
|
5336
5359
|
}
|
|
5337
|
-
var FieldSpec, RateLimit, TYPE_NAME, TypeConfig,
|
|
5360
|
+
var FieldSpec, RateLimit, TYPE_NAME, TypeConfig, SiteConfig;
|
|
5338
5361
|
var init_site_config = __esm({
|
|
5339
5362
|
"../schema/src/site-config.ts"() {
|
|
5340
5363
|
"use strict";
|
|
5341
5364
|
init_zod();
|
|
5365
|
+
init_query();
|
|
5342
5366
|
FieldSpec = external_exports.enum(["string", "string?", "number", "number?", "boolean", "boolean?"]);
|
|
5343
5367
|
RateLimit = external_exports.string().regex(/^\d+\/(minute|hour|day)$/, 'rate_limit must look like "5/hour"');
|
|
5344
5368
|
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);
|
|
@@ -5364,10 +5388,6 @@ var init_site_config = __esm({
|
|
|
5364
5388
|
parent_types: external_exports.array(external_exports.string().min(1)).optional(),
|
|
5365
5389
|
rate_limit: RateLimit.optional()
|
|
5366
5390
|
}).strict();
|
|
5367
|
-
IndexDecl = external_exports.object({
|
|
5368
|
-
type: external_exports.string().min(1),
|
|
5369
|
-
path: external_exports.string().regex(/^\$(\.[A-Za-z0-9_]+)+$/, 'index paths are scalar JSON paths like "$.date"')
|
|
5370
|
-
}).strict();
|
|
5371
5391
|
SiteConfig = external_exports.object({
|
|
5372
5392
|
/** Sitename: lowercase alphanumeric + hyphens, unique per user. */
|
|
5373
5393
|
site: external_exports.string().regex(/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/, "site is lowercase letters, numbers and hyphens").optional(),
|
|
@@ -5378,7 +5398,12 @@ var init_site_config = __esm({
|
|
|
5378
5398
|
// cannot be parameterised. Keeping them to this alphabet is what makes that
|
|
5379
5399
|
// interpolation safe, rather than relying on escaping at each use.
|
|
5380
5400
|
types: external_exports.record(TYPE_NAME, TypeConfig).default({}),
|
|
5381
|
-
|
|
5401
|
+
/**
|
|
5402
|
+
* What each type's pages will filter, sort and search on. One entry is one
|
|
5403
|
+
* index is one extra billed write per row of that type; an undeclared query
|
|
5404
|
+
* is refused rather than allowed to scan (query surface §1, §4).
|
|
5405
|
+
*/
|
|
5406
|
+
query: QueryConfig.default({}),
|
|
5382
5407
|
/** Pro (§3): additive custom-schema SQL applied to the site database. */
|
|
5383
5408
|
schema: external_exports.string().max(2e4).optional()
|
|
5384
5409
|
}).strict();
|
|
@@ -5397,6 +5422,7 @@ var init_src = __esm({
|
|
|
5397
5422
|
"../schema/src/index.ts"() {
|
|
5398
5423
|
"use strict";
|
|
5399
5424
|
init_universal();
|
|
5425
|
+
init_query();
|
|
5400
5426
|
init_names();
|
|
5401
5427
|
init_site_config();
|
|
5402
5428
|
init_bundle();
|
|
@@ -24327,59 +24353,81 @@ function registerTools(server, ctx) {
|
|
|
24327
24353
|
"query_db",
|
|
24328
24354
|
{
|
|
24329
24355
|
title: "Query site database",
|
|
24330
|
-
description:
|
|
24356
|
+
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.',
|
|
24331
24357
|
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
24332
24358
|
inputSchema: {
|
|
24333
24359
|
site: external_exports.string().describe("Site name or id"),
|
|
24334
|
-
action: external_exports.enum(["types", "list", "get"]).describe("types = row counts per type; list = rows of a type; get = one row by
|
|
24360
|
+
action: external_exports.enum(["types", "list", "get"]).describe("types = row counts per type; list = rows of a type; get = one row by seq"),
|
|
24335
24361
|
type: external_exports.string().optional().describe("Row type \u2014 required for list"),
|
|
24336
|
-
|
|
24337
|
-
|
|
24362
|
+
seq: external_exports.number().int().optional().describe("Row number \u2014 required for get"),
|
|
24363
|
+
filter: external_exports.record(external_exports.unknown()).optional().describe(
|
|
24364
|
+
'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.'
|
|
24365
|
+
),
|
|
24366
|
+
sort: external_exports.string().optional().describe(
|
|
24367
|
+
'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.'
|
|
24368
|
+
),
|
|
24369
|
+
search: external_exports.string().optional().describe(
|
|
24370
|
+
"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`."
|
|
24371
|
+
),
|
|
24372
|
+
parent_seq: external_exports.number().int().optional().describe("list: only rows whose parent is this row number"),
|
|
24338
24373
|
limit: external_exports.number().int().optional().describe("list: max rows (default 100, cap 500)"),
|
|
24339
|
-
|
|
24374
|
+
cursor: external_exports.string().optional().describe(
|
|
24375
|
+
"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."
|
|
24376
|
+
),
|
|
24377
|
+
offset: external_exports.number().int().optional().describe(
|
|
24378
|
+
"list: rows to skip, for numbered pages. Capped at 1000 because offset walks past every row it skips; past that use cursor."
|
|
24379
|
+
),
|
|
24380
|
+
count: external_exports.boolean().optional().describe(
|
|
24381
|
+
'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.'
|
|
24382
|
+
)
|
|
24340
24383
|
}
|
|
24341
24384
|
},
|
|
24342
|
-
async ({
|
|
24343
|
-
site,
|
|
24344
|
-
action,
|
|
24345
|
-
type,
|
|
24346
|
-
id,
|
|
24347
|
-
parent_id,
|
|
24348
|
-
limit,
|
|
24349
|
-
offset
|
|
24350
|
-
}) => {
|
|
24385
|
+
async (args) => {
|
|
24386
|
+
const { site, action, type, seq } = args;
|
|
24351
24387
|
try {
|
|
24352
24388
|
switch (action) {
|
|
24353
24389
|
case "types":
|
|
24354
24390
|
return ok(await ctx.api.request("GET", `${sitePath(site)}/data/types`));
|
|
24355
24391
|
case "list": {
|
|
24356
|
-
if (
|
|
24392
|
+
if (!type) {
|
|
24357
24393
|
throw new ToolApiError(422, {
|
|
24358
24394
|
error: {
|
|
24359
|
-
code: "
|
|
24360
|
-
message:
|
|
24361
|
-
next:
|
|
24395
|
+
code: "missing_type",
|
|
24396
|
+
message: 'query_db action "list" needs `type`.',
|
|
24397
|
+
next: 'See what types exist with action "types".'
|
|
24362
24398
|
}
|
|
24363
24399
|
});
|
|
24364
24400
|
}
|
|
24365
|
-
const params = new URLSearchParams();
|
|
24366
|
-
|
|
24367
|
-
|
|
24368
|
-
|
|
24369
|
-
|
|
24401
|
+
const params = new URLSearchParams({ type });
|
|
24402
|
+
for (const [field, value] of Object.entries(args.filter ?? {})) {
|
|
24403
|
+
if (value !== null && typeof value === "object" && !Array.isArray(value)) {
|
|
24404
|
+
for (const [op, bound] of Object.entries(value)) {
|
|
24405
|
+
params.set(`filter.${field}.${op}`, String(bound));
|
|
24406
|
+
}
|
|
24407
|
+
} else {
|
|
24408
|
+
params.set(`filter.${field}`, String(value));
|
|
24409
|
+
}
|
|
24410
|
+
}
|
|
24411
|
+
if (args.sort) params.set("sort", args.sort);
|
|
24412
|
+
if (args.search) params.set("search", args.search);
|
|
24413
|
+
if (args.parent_seq !== void 0) params.set("parent", String(args.parent_seq));
|
|
24414
|
+
if (args.limit !== void 0) params.set("limit", String(args.limit));
|
|
24415
|
+
if (args.cursor) params.set("cursor", args.cursor);
|
|
24416
|
+
if (args.offset !== void 0) params.set("offset", String(args.offset));
|
|
24417
|
+
if (args.count) params.set("count", "1");
|
|
24370
24418
|
return ok(await ctx.api.request("GET", `${sitePath(site)}/data?${params}`));
|
|
24371
24419
|
}
|
|
24372
24420
|
case "get": {
|
|
24373
|
-
if (
|
|
24421
|
+
if (seq === void 0) {
|
|
24374
24422
|
throw new ToolApiError(422, {
|
|
24375
24423
|
error: {
|
|
24376
|
-
code: "
|
|
24377
|
-
message: 'query_db action "get" needs `
|
|
24378
|
-
next: '
|
|
24424
|
+
code: "missing_seq",
|
|
24425
|
+
message: 'query_db action "get" needs `seq` \u2014 the row number.',
|
|
24426
|
+
next: 'Row numbers come back with every row from action "list".'
|
|
24379
24427
|
}
|
|
24380
24428
|
});
|
|
24381
24429
|
}
|
|
24382
|
-
return ok(await ctx.api.request("GET", `${sitePath(site)}/data?
|
|
24430
|
+
return ok(await ctx.api.request("GET", `${sitePath(site)}/data?seq=${encodeURIComponent(String(seq))}`));
|
|
24383
24431
|
}
|
|
24384
24432
|
}
|
|
24385
24433
|
} catch (error2) {
|
|
@@ -24391,18 +24439,24 @@ function registerTools(server, ctx) {
|
|
|
24391
24439
|
"write_db",
|
|
24392
24440
|
{
|
|
24393
24441
|
title: "Write site database",
|
|
24394
|
-
description: "Write to the site's database as its owner: insert, update, or delete
|
|
24442
|
+
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.",
|
|
24395
24443
|
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
|
|
24396
24444
|
inputSchema: {
|
|
24397
24445
|
site: external_exports.string().describe("Site name or id"),
|
|
24398
24446
|
action: external_exports.enum(["insert", "update", "delete"]).describe("What to do with the row"),
|
|
24399
24447
|
type: external_exports.string().optional().describe("Row type \u2014 required for insert"),
|
|
24400
|
-
|
|
24401
|
-
|
|
24402
|
-
json: external_exports.record(external_exports.unknown()).optional().describe("The row's full document \u2014
|
|
24448
|
+
seq: external_exports.number().int().optional().describe("Row number \u2014 required for update and delete"),
|
|
24449
|
+
parent_seq: external_exports.number().int().nullable().optional().describe("Parent row NUMBER to set on insert/update (null clears it). The parent must exist."),
|
|
24450
|
+
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`)"),
|
|
24403
24451
|
merge: external_exports.record(external_exports.unknown()).optional().describe(
|
|
24404
24452
|
"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."
|
|
24405
24453
|
),
|
|
24454
|
+
rows: external_exports.array(external_exports.record(external_exports.unknown())).optional().describe(
|
|
24455
|
+
"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."
|
|
24456
|
+
),
|
|
24457
|
+
rows_url: external_exports.string().optional().describe(
|
|
24458
|
+
"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."
|
|
24459
|
+
),
|
|
24406
24460
|
validate: external_exports.boolean().optional().describe("insert/update: set false to bypass declared-type validation \u2014 only for deliberate migrations")
|
|
24407
24461
|
}
|
|
24408
24462
|
},
|
|
@@ -24410,24 +24464,26 @@ function registerTools(server, ctx) {
|
|
|
24410
24464
|
site,
|
|
24411
24465
|
action,
|
|
24412
24466
|
type,
|
|
24413
|
-
|
|
24414
|
-
|
|
24467
|
+
seq,
|
|
24468
|
+
parent_seq,
|
|
24415
24469
|
json,
|
|
24416
24470
|
merge: merge2,
|
|
24471
|
+
rows,
|
|
24472
|
+
rows_url,
|
|
24417
24473
|
validate: validate2
|
|
24418
24474
|
}) => {
|
|
24419
24475
|
try {
|
|
24420
|
-
const
|
|
24421
|
-
if (
|
|
24476
|
+
const needSeq = () => {
|
|
24477
|
+
if (seq === void 0) {
|
|
24422
24478
|
throw new ToolApiError(422, {
|
|
24423
24479
|
error: {
|
|
24424
|
-
code: "
|
|
24425
|
-
message: `write_db action "${action}" needs \`
|
|
24426
|
-
next: '
|
|
24480
|
+
code: "missing_seq",
|
|
24481
|
+
message: `write_db action "${action}" needs \`seq\` \u2014 the row number.`,
|
|
24482
|
+
next: 'Row numbers come back with every row from query_db action "list".'
|
|
24427
24483
|
}
|
|
24428
24484
|
});
|
|
24429
24485
|
}
|
|
24430
|
-
return
|
|
24486
|
+
return seq;
|
|
24431
24487
|
};
|
|
24432
24488
|
switch (action) {
|
|
24433
24489
|
case "insert": {
|
|
@@ -24435,8 +24491,8 @@ function registerTools(server, ctx) {
|
|
|
24435
24491
|
throw new ToolApiError(422, {
|
|
24436
24492
|
error: {
|
|
24437
24493
|
code: "merge_not_for_insert",
|
|
24438
|
-
message: "`merge` applies to update only \u2014 insert takes the row's
|
|
24439
|
-
next: "Pass
|
|
24494
|
+
message: "`merge` applies to update only \u2014 insert takes the row's document in `json`.",
|
|
24495
|
+
next: "Pass one document as `json`, many as `rows`, or a file as `rows_url`."
|
|
24440
24496
|
}
|
|
24441
24497
|
});
|
|
24442
24498
|
}
|
|
@@ -24449,18 +24505,40 @@ function registerTools(server, ctx) {
|
|
|
24449
24505
|
}
|
|
24450
24506
|
});
|
|
24451
24507
|
}
|
|
24508
|
+
if (rows !== void 0 || rows_url !== void 0) {
|
|
24509
|
+
if (json !== void 0) {
|
|
24510
|
+
throw new ToolApiError(422, {
|
|
24511
|
+
error: {
|
|
24512
|
+
code: "ambiguous_bulk_write",
|
|
24513
|
+
message: "Pass `json` for one row, or `rows`/`rows_url` for many \u2014 not both.",
|
|
24514
|
+
next: "Drop `json` and put that document in `rows` alongside the others."
|
|
24515
|
+
}
|
|
24516
|
+
});
|
|
24517
|
+
}
|
|
24518
|
+
return ok(
|
|
24519
|
+
await ctx.api.request("POST", `${sitePath(site)}/data`, {
|
|
24520
|
+
body: {
|
|
24521
|
+
type,
|
|
24522
|
+
...parent_seq !== void 0 ? { parent_seq } : {},
|
|
24523
|
+
...rows !== void 0 ? { rows } : {},
|
|
24524
|
+
...rows_url !== void 0 ? { rows_url } : {},
|
|
24525
|
+
...validate2 !== void 0 ? { validate: validate2 } : {}
|
|
24526
|
+
}
|
|
24527
|
+
})
|
|
24528
|
+
);
|
|
24529
|
+
}
|
|
24452
24530
|
if (json === void 0) {
|
|
24453
24531
|
throw new ToolApiError(422, {
|
|
24454
24532
|
error: {
|
|
24455
24533
|
code: "missing_json",
|
|
24456
24534
|
message: 'write_db action "insert" needs `json` \u2014 the row\'s document.',
|
|
24457
|
-
next: "Pass json: {} explicitly if the row is deliberately empty."
|
|
24535
|
+
next: "Pass json: {} explicitly if the row is deliberately empty, or use `rows` to insert many at once."
|
|
24458
24536
|
}
|
|
24459
24537
|
});
|
|
24460
24538
|
}
|
|
24461
24539
|
return ok(
|
|
24462
24540
|
await ctx.api.request("POST", `${sitePath(site)}/data`, {
|
|
24463
|
-
body: { type,
|
|
24541
|
+
body: { type, parent_seq, json, ...validate2 !== void 0 ? { validate: validate2 } : {} }
|
|
24464
24542
|
})
|
|
24465
24543
|
);
|
|
24466
24544
|
}
|
|
@@ -24475,10 +24553,10 @@ function registerTools(server, ctx) {
|
|
|
24475
24553
|
});
|
|
24476
24554
|
}
|
|
24477
24555
|
return ok(
|
|
24478
|
-
await ctx.api.request("PATCH", `${sitePath(site)}/data/${encodeURIComponent(
|
|
24556
|
+
await ctx.api.request("PATCH", `${sitePath(site)}/data/${encodeURIComponent(String(needSeq()))}`, {
|
|
24479
24557
|
body: {
|
|
24480
24558
|
...type !== void 0 ? { type } : {},
|
|
24481
|
-
...
|
|
24559
|
+
...parent_seq !== void 0 ? { parent_seq } : {},
|
|
24482
24560
|
...json !== void 0 ? { json } : {},
|
|
24483
24561
|
...merge2 !== void 0 ? { merge: merge2 } : {},
|
|
24484
24562
|
...validate2 !== void 0 ? { validate: validate2 } : {}
|
|
@@ -24486,7 +24564,9 @@ function registerTools(server, ctx) {
|
|
|
24486
24564
|
})
|
|
24487
24565
|
);
|
|
24488
24566
|
case "delete":
|
|
24489
|
-
return ok(
|
|
24567
|
+
return ok(
|
|
24568
|
+
await ctx.api.request("DELETE", `${sitePath(site)}/data/${encodeURIComponent(String(needSeq()))}`)
|
|
24569
|
+
);
|
|
24490
24570
|
}
|
|
24491
24571
|
} catch (error2) {
|
|
24492
24572
|
return toolError(error2);
|
|
@@ -24676,6 +24756,7 @@ Work token-efficiently \u2014 generated output is your scarcest resource:
|
|
|
24676
24756
|
- 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.
|
|
24677
24757
|
- 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.
|
|
24678
24758
|
- 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.
|
|
24759
|
+
- 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.
|
|
24679
24760
|
- 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.
|
|
24680
24761
|
- 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.
|
|
24681
24762
|
- 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.
|
|
@@ -24697,6 +24778,47 @@ RECIPE \u2014 public form that saves to the site's database (RSVPs, leads, guest
|
|
|
24697
24778
|
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.
|
|
24698
24779
|
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.)
|
|
24699
24780
|
|
|
24781
|
+
RECIPE \u2014 a searchable, filterable list (catalogue, directory, archive)
|
|
24782
|
+
1. Declare the type and its fields in drivethrough.json.
|
|
24783
|
+
2. Declare the queries the page will make:
|
|
24784
|
+
"query": { "movie": { "fields": ["genre", "year"],
|
|
24785
|
+
"by": [{"filter": ["genre"], "sort": "year"}],
|
|
24786
|
+
"search": ["title", "director"] } }
|
|
24787
|
+
- "fields": one entry per field you filter, range or sort on.
|
|
24788
|
+
- "by": one entry per COMBINATION \u2014 filtering on one field while sorting by
|
|
24789
|
+
another, or filtering on two at once. Omit "sort" for newest-first.
|
|
24790
|
+
- "search": the fields full-text search looks in.
|
|
24791
|
+
One entry = one index = one extra billed write per row of that type. Declare
|
|
24792
|
+
only what a page actually uses; newest-first needs no declaration. Every
|
|
24793
|
+
field named here must also exist in that type's "fields", or the deploy fails.
|
|
24794
|
+
3. Deploy. The response carries \`query_plan\`: the indexes built, the cost per
|
|
24795
|
+
insert in billed writes, and \u2014 when a declaration is added to a type that
|
|
24796
|
+
already has rows \u2014 what re-indexing them will cost. Turning search on for
|
|
24797
|
+
existing rows runs in the BACKGROUND: searches work immediately and return
|
|
24798
|
+
\`"search_status": "building"\` until it finishes. It never blocks the deploy.
|
|
24799
|
+
4. Load the data:
|
|
24800
|
+
write_db {action: "insert", type: "movie", rows: [ ... ]} a handful
|
|
24801
|
+
write_db {action: "insert", type: "movie", rows_url: "https://\u2026/movies.ndjson"} thousands
|
|
24802
|
+
Never loop single inserts \u2014 it costs 50% more and one round trip per row.
|
|
24803
|
+
An import bigger than the month's remaining write allowance is refused
|
|
24804
|
+
BEFORE anything is written, so a catalogue is never half-loaded.
|
|
24805
|
+
5. Fetch from the page, same origin:
|
|
24806
|
+
/api/data?type=movie&filter.genre=thriller&sort=-year&limit=50
|
|
24807
|
+
/api/data?type=movie&filter.year.gte=1990&filter.year.lte=1999&sort=-year
|
|
24808
|
+
/api/data?type=movie&search=crimson
|
|
24809
|
+
The response is {items, next_cursor}. Pass next_cursor back as &cursor=\u2026 for
|
|
24810
|
+
the next page \u2014 flat cost at any depth. &offset= works too but is capped at
|
|
24811
|
+
1000. &count=1 adds a count, capped at 1,000 with "count_capped": true.
|
|
24812
|
+
Each item is {seq, type, parent_seq, json, created_at, updated_at}.
|
|
24813
|
+
6. What is REFUSED, so you don't guess: filtering or sorting on an undeclared
|
|
24814
|
+
field; a combination with no "by" entry; a range not sorted by that same
|
|
24815
|
+
field; search together with sort= or with a range; a cursor from a different
|
|
24816
|
+
query; offset past 1000. Every refusal names the file, the key and the value
|
|
24817
|
+
to add \u2014 follow it.
|
|
24818
|
+
7. Verify: you have no browser, so read the deploy \`warnings\`. They name a page
|
|
24819
|
+
whose query the config doesn't declare (it will render empty for visitors),
|
|
24820
|
+
and a declaration no page uses (pure cost on every insert).
|
|
24821
|
+
|
|
24700
24822
|
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.`;
|
|
24701
24823
|
}
|
|
24702
24824
|
});
|
|
@@ -26799,11 +26921,11 @@ function isJsonMode(args) {
|
|
|
26799
26921
|
function isInteractive(args) {
|
|
26800
26922
|
return !isJsonMode(args) && !args.yes && process.stdout.isTTY === true && process.env.CI !== "true";
|
|
26801
26923
|
}
|
|
26802
|
-
function emit(args, data,
|
|
26924
|
+
function emit(args, data, human2) {
|
|
26803
26925
|
if (isJsonMode(args)) {
|
|
26804
26926
|
process.stdout.write(JSON.stringify({ ok: true, ...data }, null, 2) + "\n");
|
|
26805
26927
|
} else {
|
|
26806
|
-
|
|
26928
|
+
human2();
|
|
26807
26929
|
}
|
|
26808
26930
|
}
|
|
26809
26931
|
function fail(args, error2) {
|
|
@@ -27371,9 +27493,14 @@ var listCommand = defineCommand({
|
|
|
27371
27493
|
args: {
|
|
27372
27494
|
site: { type: "positional", description: "Site name", required: true },
|
|
27373
27495
|
type: { type: "positional", description: "Row type", required: true },
|
|
27374
|
-
parent: { type: "string", description: "Only rows under this parent
|
|
27496
|
+
parent: { type: "string", description: "Only rows under this parent row number" },
|
|
27497
|
+
filter: { type: "string", description: "Declared exact match, e.g. --filter genre=thriller (repeatable)" },
|
|
27498
|
+
sort: { type: "string", description: 'A declared field; "-" for descending, e.g. -year' },
|
|
27499
|
+
search: { type: "string", description: "Full-text search over the declared search fields" },
|
|
27375
27500
|
limit: { type: "string", description: "Max rows (default 100, cap 500)" },
|
|
27376
|
-
|
|
27501
|
+
cursor: { type: "string", description: "next_cursor from the previous page" },
|
|
27502
|
+
offset: { type: "string", description: "Rows to skip (numbered pages, capped at 1000)" },
|
|
27503
|
+
count: { type: "boolean", description: "Include a result count (capped at 1,000)" },
|
|
27377
27504
|
json: { type: "boolean", description: "Machine-readable output" }
|
|
27378
27505
|
},
|
|
27379
27506
|
async run({ args }) {
|
|
@@ -27381,8 +27508,16 @@ var listCommand = defineCommand({
|
|
|
27381
27508
|
try {
|
|
27382
27509
|
const params = new URLSearchParams({ type: args.type });
|
|
27383
27510
|
if (args.parent) params.set("parent", args.parent);
|
|
27511
|
+
for (const raw of [].concat(args.filter ?? [])) {
|
|
27512
|
+
const eq = raw.indexOf("=");
|
|
27513
|
+
if (eq > 0) params.set(`filter.${raw.slice(0, eq)}`, raw.slice(eq + 1));
|
|
27514
|
+
}
|
|
27515
|
+
if (args.sort) params.set("sort", args.sort);
|
|
27516
|
+
if (args.search) params.set("search", args.search);
|
|
27384
27517
|
if (args.limit) params.set("limit", args.limit);
|
|
27518
|
+
if (args.cursor) params.set("cursor", args.cursor);
|
|
27385
27519
|
if (args.offset) params.set("offset", args.offset);
|
|
27520
|
+
if (args.count) params.set("count", "1");
|
|
27386
27521
|
const res = await api("GET", `/v1/sites/${args.site}/data?${params}`);
|
|
27387
27522
|
emit(jsonArgs, { ...res }, () => {
|
|
27388
27523
|
if (res.items.length === 0) {
|
|
@@ -27390,8 +27525,16 @@ var listCommand = defineCommand({
|
|
|
27390
27525
|
return;
|
|
27391
27526
|
}
|
|
27392
27527
|
for (const row of res.items) {
|
|
27393
|
-
M2.message(` ${row.
|
|
27528
|
+
M2.message(` ${row.seq} ${JSON.stringify(row.json)}`);
|
|
27529
|
+
}
|
|
27530
|
+
if (res.count !== void 0) {
|
|
27531
|
+
M2.message(` ${res.count}${res.count_capped ? "+" : ""} total`);
|
|
27394
27532
|
}
|
|
27533
|
+
if (res.search_status === "building") {
|
|
27534
|
+
M2.warn("Search is still indexing existing rows \u2014 these are the ones indexed so far.");
|
|
27535
|
+
}
|
|
27536
|
+
if (res.partial) M2.warn(`Partial result \u2014 this query isn't declared. ${res.declare ?? ""}`);
|
|
27537
|
+
if (res.next_cursor) M2.message(` next: --cursor ${res.next_cursor}`);
|
|
27395
27538
|
});
|
|
27396
27539
|
} catch (error2) {
|
|
27397
27540
|
fail(jsonArgs, error2);
|
|
@@ -27399,10 +27542,10 @@ var listCommand = defineCommand({
|
|
|
27399
27542
|
}
|
|
27400
27543
|
});
|
|
27401
27544
|
var getCommand = defineCommand({
|
|
27402
|
-
meta: { name: "get", description: "Fetch one row by
|
|
27545
|
+
meta: { name: "get", description: "Fetch one row by its row number" },
|
|
27403
27546
|
args: {
|
|
27404
27547
|
site: { type: "positional", description: "Site name", required: true },
|
|
27405
|
-
|
|
27548
|
+
seq: { type: "positional", description: "Row number (seq)", required: true },
|
|
27406
27549
|
json: { type: "boolean", description: "Machine-readable output" }
|
|
27407
27550
|
},
|
|
27408
27551
|
async run({ args }) {
|
|
@@ -27410,7 +27553,7 @@ var getCommand = defineCommand({
|
|
|
27410
27553
|
try {
|
|
27411
27554
|
const res = await api(
|
|
27412
27555
|
"GET",
|
|
27413
|
-
`/v1/sites/${args.site}/data?
|
|
27556
|
+
`/v1/sites/${args.site}/data?seq=${encodeURIComponent(args.seq)}`
|
|
27414
27557
|
);
|
|
27415
27558
|
emit(jsonArgs, { item: res.items[0] }, () => {
|
|
27416
27559
|
M2.message(JSON.stringify(res.items[0], null, 2));
|
|
@@ -27659,27 +27802,44 @@ var collaboratorsCommand = defineCommand({
|
|
|
27659
27802
|
|
|
27660
27803
|
// src/commands/usage.ts
|
|
27661
27804
|
init_api();
|
|
27805
|
+
function human(value, unit) {
|
|
27806
|
+
if (unit === "bytes") {
|
|
27807
|
+
if (value >= 1024 ** 3) return `${(value / 1024 ** 3).toFixed(2)} GB`;
|
|
27808
|
+
return `${(value / 1024 ** 2).toFixed(1)} MB`;
|
|
27809
|
+
}
|
|
27810
|
+
return value.toLocaleString("en-US");
|
|
27811
|
+
}
|
|
27662
27812
|
var usageCommand = defineCommand({
|
|
27663
|
-
meta: { name: "usage", description: "Show
|
|
27813
|
+
meta: { name: "usage", description: "Show this month's usage against your plan's limits" },
|
|
27664
27814
|
args: {
|
|
27665
|
-
site: { type: "positional", description: "Site name", required:
|
|
27815
|
+
site: { type: "positional", description: "Site name (omit for the account rollup)", required: false },
|
|
27666
27816
|
json: { type: "boolean", description: "Machine-readable output" }
|
|
27667
27817
|
},
|
|
27668
27818
|
async run({ args }) {
|
|
27669
27819
|
const jsonArgs = { json: args.json };
|
|
27670
27820
|
try {
|
|
27821
|
+
if (!args.site) {
|
|
27822
|
+
const usage2 = await api("GET", `/v1/usage`);
|
|
27823
|
+
emit(jsonArgs, { ...usage2 }, () => {
|
|
27824
|
+
M2.message(`Plan: ${usage2.plan} \u2014 resets ${new Date(usage2.period.resets_at).toDateString()}`);
|
|
27825
|
+
for (const meter of usage2.meters) {
|
|
27826
|
+
const line = `${meter.label.padEnd(18)} ${human(meter.used, meter.unit)} / ${human(meter.limit, meter.unit)} (${meter.percent}%)${meter.lagged ? " ~" : ""}`;
|
|
27827
|
+
if (meter.state === "over") M2.error(line);
|
|
27828
|
+
else if (meter.state === "warn") M2.warn(line);
|
|
27829
|
+
else M2.message(line);
|
|
27830
|
+
}
|
|
27831
|
+
});
|
|
27832
|
+
return;
|
|
27833
|
+
}
|
|
27671
27834
|
const usage = await api("GET", `/v1/sites/${args.site}/usage`);
|
|
27672
27835
|
emit(jsonArgs, { ...usage }, () => {
|
|
27673
|
-
M2.message(`Storage:
|
|
27836
|
+
M2.message(`Storage: ${(usage.storage_bytes / 1024 / 1024).toFixed(2)} MB`);
|
|
27674
27837
|
M2.message(`Writes today: ${usage.d1_writes_today}`);
|
|
27675
|
-
M2.message(
|
|
27676
|
-
|
|
27677
|
-
|
|
27678
|
-
|
|
27679
|
-
|
|
27680
|
-
}
|
|
27681
|
-
} else {
|
|
27682
|
-
M2.success("No index suggestions \u2014 your queries are index-friendly.");
|
|
27838
|
+
M2.message(
|
|
27839
|
+
`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`
|
|
27840
|
+
);
|
|
27841
|
+
if (usage.query_indexes.length) {
|
|
27842
|
+
M2.message(`Query indexes: ${usage.query_indexes.join(", ")}`);
|
|
27683
27843
|
}
|
|
27684
27844
|
});
|
|
27685
27845
|
} catch (error2) {
|
|
@@ -27769,15 +27929,115 @@ Declare data types the site collects. The platform owns the write path.
|
|
|
27769
27929
|
\`"read": "public"\` explicitly to publish submissions, e.g. a public guestbook.
|
|
27770
27930
|
- \`unique_per: "session"\`: the row edits itself (an RSVP updates, never duplicates).
|
|
27771
27931
|
- \`notify: "owner-email"\`: the owner gets an email on each entry (leads/RSVPs).
|
|
27772
|
-
- \`indexes\`: [{ "type": "event", "path": "$.date" }] for larger datasets.
|
|
27773
27932
|
|
|
27774
27933
|
Deploying a drivethrough.json with types auto-provisions the site's database.
|
|
27775
27934
|
|
|
27935
|
+
## Filtering, sorting and searching \u2014 the \`query\` block
|
|
27936
|
+
|
|
27937
|
+
**A query is either declared, or it is refused.** There is no third state where
|
|
27938
|
+
a visitor's dropdown quietly scans the whole table, because a page whose cost
|
|
27939
|
+
grows with the table is a page that gets slower as the site gets more popular.
|
|
27940
|
+
|
|
27941
|
+
\`\`\`json
|
|
27942
|
+
{
|
|
27943
|
+
"types": {
|
|
27944
|
+
"movie": {
|
|
27945
|
+
"write": "owner", "read": "public",
|
|
27946
|
+
"fields": { "title": "string", "genre": "string", "year": "number", "director": "string" }
|
|
27947
|
+
}
|
|
27948
|
+
},
|
|
27949
|
+
"query": {
|
|
27950
|
+
"movie": {
|
|
27951
|
+
"fields": ["genre", "year"],
|
|
27952
|
+
"by": [{ "filter": ["genre"], "sort": "year" }],
|
|
27953
|
+
"search": ["title", "director"]
|
|
27954
|
+
}
|
|
27955
|
+
}
|
|
27956
|
+
}
|
|
27957
|
+
\`\`\`
|
|
27958
|
+
|
|
27959
|
+
Three keys, and they are the whole model:
|
|
27960
|
+
- \`fields\` \u2014 one entry per field you filter, range or sort on.
|
|
27961
|
+
- \`by\` \u2014 one entry per COMBINATION: filtering on one field while sorting by
|
|
27962
|
+
another, or filtering on two at once. Omit \`sort\` for newest-first.
|
|
27963
|
+
- \`search\` \u2014 the fields full-text search looks in.
|
|
27964
|
+
|
|
27965
|
+
One entry = one index = one extra billed write on every row of that type, so
|
|
27966
|
+
declare only what a page actually uses. Every field named under \`query\` must
|
|
27967
|
+
also exist in that type's \`fields\`, or the deploy fails. An insert may cost at
|
|
27968
|
+
most 8 billed writes per type (2 base + up to 5 shapes + search).
|
|
27969
|
+
|
|
27970
|
+
**Newest-first needs no declaration and is always free** \u2014 \`sort=-created_at\`
|
|
27971
|
+
(the default) and \`sort=created_at\` are served by an index every site already
|
|
27972
|
+
has. A document's own date (a post's publish date, an event's start) is an
|
|
27973
|
+
ordinary field you declare like any other; it is content, not bookkeeping.
|
|
27974
|
+
|
|
27975
|
+
The deploy response carries \`query_plan\`: the indexes built, the cost per
|
|
27976
|
+
insert, and \u2014 when you add a declaration to a type that already has rows \u2014 what
|
|
27977
|
+
re-indexing them will cost, before it happens.
|
|
27978
|
+
|
|
27776
27979
|
## Client-side data access
|
|
27777
27980
|
Static pages call the same-origin Data API \u2014 no SDK required:
|
|
27778
|
-
- \`POST /api/data\` with JSON \`{type, json: {\u2026fields}, turnstile_token?}\`
|
|
27981
|
+
- \`POST /api/data\` with JSON \`{type, json: {\u2026fields}, parent_seq?, turnstile_token?}\`
|
|
27779
27982
|
inserts (turnstile_token only for anonymous writes \u2014 wiring above).
|
|
27780
|
-
- \`GET /api/data?type=X\` \u2192 \`{items}\` lists the rows the caller
|
|
27983
|
+
- \`GET /api/data?type=X\` \u2192 \`{items, next_cursor}\` lists the rows the caller
|
|
27984
|
+
may read. Each item is \`{seq, type, parent_seq, json, created_at, updated_at}\`.
|
|
27985
|
+
\`seq\` is the row number and its permanent identity \u2014 never reused, so a URL
|
|
27986
|
+
like \`/movie/402\` can never come back pointing at a different row.
|
|
27987
|
+
|
|
27988
|
+
Everything a page can ask for, in full:
|
|
27989
|
+
\`\`\`
|
|
27990
|
+
GET /api/data
|
|
27991
|
+
?type=movie required \u2014 which kind of row
|
|
27992
|
+
&filter.genre=thriller exact match on a declared field
|
|
27993
|
+
&filter.year.gte=1990 range: .gte .lte .gt .lt
|
|
27994
|
+
&search=crimson midnight full-text over declared search fields
|
|
27995
|
+
&sort=-year declared field; "-" means descending
|
|
27996
|
+
&limit=50 1\u2013500, default 100
|
|
27997
|
+
&cursor=eyJ2IjoxLCJzIjo\u2026 next page (next_cursor from the last response)
|
|
27998
|
+
&offset=200 0\u20131000 only; past that, use cursor
|
|
27999
|
+
&count=1 include a count, capped at 1,000
|
|
28000
|
+
&parent=41 only rows whose parent is this row
|
|
28001
|
+
\`\`\`
|
|
28002
|
+
|
|
28003
|
+
**Pagination:** pass \`next_cursor\` back as \`&cursor=\u2026\` \u2014 flat cost at any
|
|
28004
|
+
depth, which is how a page reads past its first 1,000 rows. \`offset\` is a
|
|
28005
|
+
convenience for numbered pages ("page 3") and is capped at 1000, because it
|
|
28006
|
+
walks past every row it skips.
|
|
28007
|
+
|
|
28008
|
+
**Search** is ranked by relevance, so it does NOT combine with \`sort=\` or with
|
|
28009
|
+
a range, and it pages by \`offset\` (up to 1000) rather than by cursor. It does
|
|
28010
|
+
combine with exact \`filter\` values. While a newly-declared search is still
|
|
28011
|
+
indexing existing rows, responses carry \`"search_status": "building"\` \u2014 show
|
|
28012
|
+
the results you got, not an empty state.
|
|
28013
|
+
|
|
28014
|
+
**What gets refused, so you don't guess:** filtering or sorting on a field not
|
|
28015
|
+
in \`query.<type>.fields\`; a combination with no \`by\` entry; a range not sorted
|
|
28016
|
+
by that same field; \`search\` with \`sort\` or with a range; a cursor from a
|
|
28017
|
+
different query; \`offset\` past 1000. Every refusal is
|
|
28018
|
+
\`{error: {code, message, next}}\` and \`next\` names the file, the key and the
|
|
28019
|
+
value to add \u2014 follow it rather than working around it.
|
|
28020
|
+
|
|
28021
|
+
## Loading a lot of data
|
|
28022
|
+
Never loop single inserts: one row per call costs 50% more in billed writes and
|
|
28023
|
+
one HTTP round trip each.
|
|
28024
|
+
- A handful to a few thousand rows: \`write_db {action: "insert", type, rows: [...]}\`
|
|
28025
|
+
\u2014 batched into ~250-row statements server-side.
|
|
28026
|
+
- Thousands and up: \`write_db {action: "insert", type, rows_url: "https://\u2026/movies.ndjson"}\`
|
|
28027
|
+
\u2014 the platform fetches and parses it server-side (NDJSON, CSV with a header
|
|
28028
|
+
row, or a JSON array), so you never have to hold or emit the data. This is the
|
|
28029
|
+
only way a large catalogue gets loaded at all.
|
|
28030
|
+
|
|
28031
|
+
\`created_at\` is always the moment the platform wrote the row and is never read
|
|
28032
|
+
from an import \u2014 that is what keeps free newest-first ordering honest. A row's
|
|
28033
|
+
own date is an ordinary declared field.
|
|
28034
|
+
|
|
28035
|
+
Writes count against the account's monthly allowance (5,000 / 50,000 / 250,000
|
|
28036
|
+
on Free / Personal / Pro), owner writes included, and so do index rebuilds and
|
|
28037
|
+
search backfills. An import bigger than what remains is refused **before the
|
|
28038
|
+
first row is written** \u2014 the error names all three ways out: import fewer rows,
|
|
28039
|
+
wait for the reset date it gives you, or upgrade. A half-loaded catalogue is a
|
|
28040
|
+
bug, not a degraded success, so this never partially succeeds.
|
|
27781
28041
|
A typed helper ships in the starter template and keeps it legible:
|
|
27782
28042
|
\`\`\`ts
|
|
27783
28043
|
import { data } from "./lib/data"; // ships in the starter template
|
|
@@ -27798,13 +28058,17 @@ continue in a later session:
|
|
|
27798
28058
|
- \`drivethrough files <site> --json\` \u2014 the deployed version's manifest and
|
|
27799
28059
|
file contents (or one file: \`drivethrough files <site> /index.html\`).
|
|
27800
28060
|
- \`drivethrough data types <site>\` / \`data list <site> <type>\` /
|
|
27801
|
-
\`data get <site> <
|
|
28061
|
+
\`data get <site> <seq>\` \u2014 inspect the site's database rows.
|
|
27802
28062
|
- MCP: \`get_site\` and \`query_db\` do the same. Reads and writes are separate
|
|
27803
|
-
tools: \`query_db\` lists types, lists rows
|
|
27804
|
-
|
|
27805
|
-
|
|
27806
|
-
|
|
27807
|
-
|
|
28063
|
+
tools: \`query_db\` lists types, lists rows (with the same \`filter\`, \`sort\`,
|
|
28064
|
+
\`search\`, \`cursor\` and \`count\` the pages use), and fetches one row by
|
|
28065
|
+
\`seq\`; \`write_db\` inserts, imports, updates, and deletes. Writes are
|
|
28066
|
+
validated against the site's declared types \u2014 pass \`validate: false\` only for
|
|
28067
|
+
deliberate migrations \u2014 and update with \`merge\` to change a few fields
|
|
28068
|
+
without resending the document. As the owner, a query the config doesn't
|
|
28069
|
+
declare gives you a bounded 5,000-row scan marked \`"partial": true\` instead
|
|
28070
|
+
of a refusal, so you can explore data you haven't modelled \u2014 but a visitor
|
|
28071
|
+
page gets the refusal, so declare it before shipping the page.
|
|
27808
28072
|
- Locate before you read: \`get_site\` with \`find\` searches the deployed text
|
|
27809
28073
|
files and returns byte-exact matching lines; \`manifest_only\` lists files
|
|
27810
28074
|
without contents. Read whole files only when you truly need them.
|
|
@@ -27903,12 +28167,20 @@ This project deploys to Drivethrough. Use the \`drivethrough\` CLI (alias \`dt\`
|
|
|
27903
28167
|
so a deploy over unseen remote changes is refused (409 remote_changes) \u2014
|
|
27904
28168
|
sync down and redeploy; \`--force\` overwrites (revert can restore).
|
|
27905
28169
|
- Sites are private by default; share with \`drivethrough share <site>\`.
|
|
27906
|
-
- Declare data behavior in \`drivethrough.json\` (
|
|
28170
|
+
- Declare data behavior in \`drivethrough.json\`: \`types\` (fields, write/read
|
|
28171
|
+
policy) and \`query\` (what pages filter, sort and search on). A query the
|
|
28172
|
+
config doesn't declare is REFUSED at request time with the exact line to add;
|
|
28173
|
+
newest-first needs no declaration. One query entry = one index = one extra
|
|
28174
|
+
billed write per row.
|
|
28175
|
+
- Load data with \`write_db {rows: [...]}\` or \`{rows_url: "https://\u2026"}\` \u2014 never
|
|
28176
|
+
a loop of single inserts. An import over the month's remaining write allowance
|
|
28177
|
+
is refused before anything is written.
|
|
27907
28178
|
- Read back what's live: \`drivethrough files <site> --json\` (deployed bundle)
|
|
27908
|
-
and \`drivethrough data types|list|get\` (database rows). Inspection only \u2014
|
|
28179
|
+
and \`drivethrough data types|list|get\` (database rows, addressed by \`seq\`). Inspection only \u2014
|
|
27909
28180
|
this repo wins AFTER sync-down; never overwrite repo \`src/\` with read-back
|
|
27910
28181
|
content (the deployed bundle is built output). Sync, edit here, rebuild,
|
|
27911
28182
|
redeploy.
|
|
28183
|
+
- Check this month's usage against every limit: \`drivethrough usage --json\`.
|
|
27912
28184
|
- Every command supports \`--json\` and non-interactive mode.
|
|
27913
28185
|
- Full guide: run \`drivethrough agent-setup --skill\` for SKILL.md.
|
|
27914
28186
|
`;
|