@lotics/app-sdk 0.108.0 → 0.110.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +1 -1
- package/dist/index.js +274 -41
- package/dist/queries.d.ts +6 -2
- package/docs/data_fetching.md +10 -2
- package/docs/members_and_options.md +6 -7
- package/docs/mutations.md +1 -1
- package/docs/queries.md +4 -1
- package/docs/security.md +1 -1
- package/package.json +1 -1
package/AGENTS.md
CHANGED
|
@@ -14,7 +14,7 @@ This file is the index. The **exact type** of anything is its shipped declaratio
|
|
|
14
14
|
|
|
15
15
|
| Doc | Read it for |
|
|
16
16
|
|---|---|
|
|
17
|
-
| [docs/data_fetching.md](./docs/data_fetching.md) | The reads: `useQuery(alias, params, opts)` — its rows (`limit`), numbered pages (`page`), a keyset feed (`more`), its `total` or the total alone, with `total: { by }` a count per value of one column — `useQueries` for reads known only at render, `queryAll` outside React, `exportQuery` for a file of them the server makes; every hook answers one `QueryState`. The ROW type (`RowOf` — the alias's projected columns and nothing else), runtime `sort`/`filter` keys, cell readers (`row.*`, `readSelect`, `readMembers`, `readLinks`, `readFiles`, `readLocked`, `readCreatedAt`/`readUpdatedAt`), the SDK's own cache — **arrival revalidates** — **realtime push**, a write drawn on every read from the press, a count as its own full scan, and the search-as-you-type and record-picker patterns. |
|
|
17
|
+
| [docs/data_fetching.md](./docs/data_fetching.md) | The reads: `useQuery(alias, params, opts)` — its rows (`limit`), numbered pages (`page`), a keyset feed (`more`), its `total` or the total alone, with `total: { by }` a count per value of one column and `total: { where }` a count per named filter, from one scan — `useQueries` for reads known only at render, `queryAll` outside React, `exportQuery` for a file of them the server makes; every hook answers one `QueryState`. The ROW type (`RowOf` — the alias's projected columns and nothing else), runtime `sort`/`filter` keys, cell readers (`row.*`, `readSelect`, `readMembers`, `readLinks`, `readFiles`, `readLocked`, `readCreatedAt`/`readUpdatedAt`), the SDK's own cache — **arrival revalidates** — **realtime push**, a write drawn on every read from the press, a count as its own full scan, and the search-as-you-type and record-picker patterns. |
|
|
18
18
|
| [docs/queries.md](./docs/queries.md) | **The query engine reference** — AST node kinds, per-field-type operators, filters/params/pruning, free-text search, combining tables, shaping (aggregates, date buckets, windows), runtime refinement bounds, limits and the efficiency playbook. |
|
|
19
19
|
| [docs/mutations.md](./docs/mutations.md) | `useWorkflow` (the ONLY write path; `useWorkflows` for writes that are data), the `WorkflowResult` resolve-never-throw contract (`field_errors`), typed inputs, every write drawn on every read from the press — predicted from the workflow's own steps, taken back on a refusal, `pending` meanwhile — the re-read a successful write triggers over the tables its body names, diff-before-update, locked records, `useNewRecord`, read-after-write ordering (`writesSettled()`), `useRecording`, `useRecordings` over several aliases. |
|
|
20
20
|
| [docs/workflows.md](./docs/workflows.md) | **The workflow-BODY reference** — the JS subset a body may use, opaque `fld_*`/`opt_*` keys, every step form, the accepted sugar, helpers, record-write surfaces, the traps and the verify loop. |
|
package/dist/index.js
CHANGED
|
@@ -15261,6 +15261,198 @@ function isEmptyFieldValue(value) {
|
|
|
15261
15261
|
return false;
|
|
15262
15262
|
}
|
|
15263
15263
|
|
|
15264
|
+
// ../shared/src/schemas/files.ts
|
|
15265
|
+
var processedFileSchema = zod_default.object({
|
|
15266
|
+
id: zod_default.string().describe("Unique identifier for the processed file"),
|
|
15267
|
+
filename: zod_default.string().describe("Original filename of the uploaded file"),
|
|
15268
|
+
mime_type: zod_default.string().describe("MIME type of the file (e.g., 'image/png', 'application/pdf')"),
|
|
15269
|
+
// Stable proxy URL (`/v1/files/<file_storage_key>`), resolved at serving time
|
|
15270
|
+
// — never stored in the DB.
|
|
15271
|
+
url: zod_default.string().optional().describe("Stable file serving URL, resolved at serving time"),
|
|
15272
|
+
thumbnail_url: zod_default.string().optional().describe("Thumbnail URL for image files, resolved at serving time"),
|
|
15273
|
+
preview_url: zod_default.string().optional().describe("Preview URL for legacy Excel files, resolved at serving time"),
|
|
15274
|
+
provider: zod_default.string().describe("Storage provider name (e.g., 'aws', 'gcs')"),
|
|
15275
|
+
file_storage_key: zod_default.string().describe("Storage key/path where the file is stored"),
|
|
15276
|
+
workspace_id: zod_default.string().describe("ID of the workspace this file belongs to"),
|
|
15277
|
+
size: zod_default.number().nullish().describe("Byte size of the stored object (null until backfilled)"),
|
|
15278
|
+
created_at: zod_default.string().describe("Timestamp when file was uploaded"),
|
|
15279
|
+
metadata: zod_default.unknown().describe("Additional metadata about the file"),
|
|
15280
|
+
data: zod_default.unknown().describe("File-type-specific data (e.g., {width, height, orientation} for images)")
|
|
15281
|
+
});
|
|
15282
|
+
var fileUploadErrorSchema = zod_default.object({
|
|
15283
|
+
filename: zod_default.string().describe("Original filename that failed to upload"),
|
|
15284
|
+
error: zod_default.string().describe("Error message describing the failure reason")
|
|
15285
|
+
});
|
|
15286
|
+
var fileUploadResponseSchema = zod_default.object({
|
|
15287
|
+
files: zod_default.array(processedFileSchema).describe("Successfully uploaded files"),
|
|
15288
|
+
errors: zod_default.array(fileUploadErrorSchema).describe("Files that failed to upload with error details")
|
|
15289
|
+
});
|
|
15290
|
+
var fileFieldValueSchema = zod_default.object({
|
|
15291
|
+
id: zod_default.string().describe("Unique identifier for the file"),
|
|
15292
|
+
/**
|
|
15293
|
+
* Present on a STORED cell and on its way off responses.
|
|
15294
|
+
*
|
|
15295
|
+
* Optional here because this schema types both, and a response is the half
|
|
15296
|
+
* that loses it — no client reads it, and one that required it would reject
|
|
15297
|
+
* the first response without it. Nothing weakens at write time: a file cell
|
|
15298
|
+
* is written from file IDS and `resolveFileFieldIds` mints this from the
|
|
15299
|
+
* `files` row, so a caller never supplies it and a stored cell always has it.
|
|
15300
|
+
* Code that derives a serving URL guards for it (`file_url_resolver.ts`).
|
|
15301
|
+
*/
|
|
15302
|
+
file_storage_key: zod_default.string().optional().describe("Storage key for the file"),
|
|
15303
|
+
filename: zod_default.string().describe("Original filename"),
|
|
15304
|
+
mime_type: zod_default.string().describe("MIME type of the file"),
|
|
15305
|
+
url: zod_default.string().optional().describe("Public URL for the file, resolved at serving time"),
|
|
15306
|
+
thumbnail_url: zod_default.string().optional().describe("Thumbnail URL for image files, resolved at serving time"),
|
|
15307
|
+
preview_url: zod_default.string().optional().describe("Preview URL for legacy Excel files, resolved at serving time"),
|
|
15308
|
+
// Immutable file-object metadata, resolved at serving time from the `files`
|
|
15309
|
+
// row (never stored on the denormalized cell) — like `url`. `size` may be
|
|
15310
|
+
// absent for older files not yet backfilled.
|
|
15311
|
+
size: zod_default.number().nullish().describe("Byte size, resolved at serving time from the file object (null/absent until backfilled)"),
|
|
15312
|
+
created_at: zod_default.string().optional().describe("Upload timestamp, resolved at serving time from the file object"),
|
|
15313
|
+
document_template_id: zod_default.string().optional().describe("The document template the file was generated from, resolved at serving time from the file object; absent on a file a person brought")
|
|
15314
|
+
});
|
|
15315
|
+
function storedFileIds(list2) {
|
|
15316
|
+
if (!Array.isArray(list2)) return [];
|
|
15317
|
+
return list2.flatMap((entry) => {
|
|
15318
|
+
if (typeof entry === "string") return [entry];
|
|
15319
|
+
const id = typeof entry === "object" && entry !== null ? Reflect.get(entry, "id") : void 0;
|
|
15320
|
+
return typeof id === "string" ? [id] : [];
|
|
15321
|
+
});
|
|
15322
|
+
}
|
|
15323
|
+
function cellFileIds(cell) {
|
|
15324
|
+
if (!Array.isArray(cell)) return [];
|
|
15325
|
+
return cell.flatMap((entry) => Array.isArray(entry) ? cellFileIds(entry) : storedFileIds([entry]));
|
|
15326
|
+
}
|
|
15327
|
+
var servedFileSchema = zod_default.object({
|
|
15328
|
+
id: zod_default.string(),
|
|
15329
|
+
filename: zod_default.string(),
|
|
15330
|
+
mime_type: zod_default.string(),
|
|
15331
|
+
url: zod_default.string().optional().describe("Serving URL, signed for 24 hours."),
|
|
15332
|
+
thumbnail_url: zod_default.string().optional().describe("Signed thumbnail, for an image."),
|
|
15333
|
+
preview_url: zod_default.string().optional(),
|
|
15334
|
+
size: zod_default.number().nullish().describe("Bytes, where the object records it."),
|
|
15335
|
+
created_at: zod_default.string().optional().describe("When the file was uploaded."),
|
|
15336
|
+
document_template_id: zod_default.string().optional().describe("The document template the file was generated from.")
|
|
15337
|
+
});
|
|
15338
|
+
var SERVED_FILE_KEYS = servedFileSchema.keyof().options;
|
|
15339
|
+
var MAX_UPLOAD_FILE_SIZE = 2 * 1024 * 1024 * 1024;
|
|
15340
|
+
var PROFILE_IMAGE_MAX_SIZE = 10 * 1024 * 1024;
|
|
15341
|
+
var UPLOAD_TICKET_TTL_SECONDS = 5 * 60;
|
|
15342
|
+
var MAX_TICKET_UPLOAD_BYTES = 20 * 1024 * 1024;
|
|
15343
|
+
var uploadTicketRequestSchema = zod_default.object({
|
|
15344
|
+
filename: zod_default.string().min(1),
|
|
15345
|
+
mime_type: zod_default.string().optional(),
|
|
15346
|
+
/** Declared up front so an oversize file is refused before its bytes move,
|
|
15347
|
+
* rather than after the transfer is already spent. Optional because a caller
|
|
15348
|
+
* streaming from a pipe may genuinely not know; the PUT enforces the cap
|
|
15349
|
+
* either way. */
|
|
15350
|
+
size_bytes: zod_default.number().int().positive().optional()
|
|
15351
|
+
});
|
|
15352
|
+
var uploadTicketResponseSchema = zod_default.object({
|
|
15353
|
+
upload_url: zod_default.string(),
|
|
15354
|
+
ticket_id: zod_default.string(),
|
|
15355
|
+
method: zod_default.literal("PUT"),
|
|
15356
|
+
expires_at: zod_default.string(),
|
|
15357
|
+
max_bytes: zod_default.number(),
|
|
15358
|
+
required_headers: zod_default.record(zod_default.string(), zod_default.string())
|
|
15359
|
+
});
|
|
15360
|
+
var uploadTicketResultSchema = zod_default.object({
|
|
15361
|
+
file_id: zod_default.string(),
|
|
15362
|
+
file_name: zod_default.string(),
|
|
15363
|
+
mime_type: zod_default.string(),
|
|
15364
|
+
size_bytes: zod_default.number(),
|
|
15365
|
+
sha256: zod_default.string(),
|
|
15366
|
+
/** True when these bytes were already in the workspace and no second copy was
|
|
15367
|
+
* stored. Stated rather than left to inference, because the caller then gets
|
|
15368
|
+
* back a `file_name` it did not send — the stored file's, not the one it
|
|
15369
|
+
* uploaded under — and a silent difference reads as a bug. */
|
|
15370
|
+
deduplicated: zod_default.boolean()
|
|
15371
|
+
});
|
|
15372
|
+
var fileSizeSchema = zod_default.number().int().positive().max(MAX_UPLOAD_FILE_SIZE);
|
|
15373
|
+
var directUploadInitRequestSchema = zod_default.object({
|
|
15374
|
+
file_id: zod_default.string().optional(),
|
|
15375
|
+
filename: zod_default.string(),
|
|
15376
|
+
mime_type: zod_default.string(),
|
|
15377
|
+
file_size: fileSizeSchema
|
|
15378
|
+
});
|
|
15379
|
+
var directUploadInitResponseSchema = zod_default.object({
|
|
15380
|
+
file_id: zod_default.string(),
|
|
15381
|
+
file_storage_key: zod_default.string(),
|
|
15382
|
+
upload_url: zod_default.string()
|
|
15383
|
+
});
|
|
15384
|
+
var directUploadCompleteRequestSchema = zod_default.object({
|
|
15385
|
+
file_id: zod_default.string(),
|
|
15386
|
+
file_storage_key: zod_default.string(),
|
|
15387
|
+
filename: zod_default.string(),
|
|
15388
|
+
mime_type: zod_default.string(),
|
|
15389
|
+
original_file_id: zod_default.string().optional().describe(
|
|
15390
|
+
"When set, this upload becomes a new version of the original file. The server resolves the current latest, links the new row via source_file_id, and bumps the original's latest_version_id pointer."
|
|
15391
|
+
)
|
|
15392
|
+
});
|
|
15393
|
+
var multipartInitRequestSchema = zod_default.object({
|
|
15394
|
+
file_id: zod_default.string().optional(),
|
|
15395
|
+
filename: zod_default.string(),
|
|
15396
|
+
mime_type: zod_default.string(),
|
|
15397
|
+
file_size: fileSizeSchema,
|
|
15398
|
+
network_effective_type: zod_default.enum(["slow-2g", "2g", "3g", "4g"]).optional(),
|
|
15399
|
+
network_rtt_ms: zod_default.number().nonnegative().optional()
|
|
15400
|
+
});
|
|
15401
|
+
var multipartInitResponseSchema = zod_default.object({
|
|
15402
|
+
file_id: zod_default.string(),
|
|
15403
|
+
upload_id: zod_default.string(),
|
|
15404
|
+
file_storage_key: zod_default.string(),
|
|
15405
|
+
part_size: zod_default.number(),
|
|
15406
|
+
parts: zod_default.array(
|
|
15407
|
+
zod_default.object({
|
|
15408
|
+
part_number: zod_default.number(),
|
|
15409
|
+
url: zod_default.string()
|
|
15410
|
+
})
|
|
15411
|
+
)
|
|
15412
|
+
});
|
|
15413
|
+
var multipartCompleteRequestSchema = zod_default.object({
|
|
15414
|
+
file_id: zod_default.string(),
|
|
15415
|
+
upload_id: zod_default.string(),
|
|
15416
|
+
file_storage_key: zod_default.string(),
|
|
15417
|
+
filename: zod_default.string(),
|
|
15418
|
+
mime_type: zod_default.string(),
|
|
15419
|
+
parts: zod_default.array(
|
|
15420
|
+
zod_default.object({
|
|
15421
|
+
part_number: zod_default.number(),
|
|
15422
|
+
etag: zod_default.string()
|
|
15423
|
+
})
|
|
15424
|
+
),
|
|
15425
|
+
original_file_id: zod_default.string().optional().describe(
|
|
15426
|
+
"When set, this upload becomes a new version of the original file. The server resolves the current latest, links the new row via source_file_id, and bumps the original's latest_version_id pointer."
|
|
15427
|
+
)
|
|
15428
|
+
});
|
|
15429
|
+
var multipartAbortRequestSchema = zod_default.object({
|
|
15430
|
+
upload_id: zod_default.string(),
|
|
15431
|
+
file_storage_key: zod_default.string()
|
|
15432
|
+
});
|
|
15433
|
+
var fileVersionEntrySchema = zod_default.object({
|
|
15434
|
+
id: zod_default.string(),
|
|
15435
|
+
filename: zod_default.string(),
|
|
15436
|
+
/**
|
|
15437
|
+
* Resolved by the server, because deriving it needs the storage key and a
|
|
15438
|
+
* caller that derives is a caller that must be handed one. Every other file
|
|
15439
|
+
* shape already resolves its URL at serving time; this one did not, which is
|
|
15440
|
+
* the whole reason the key still travels here.
|
|
15441
|
+
*/
|
|
15442
|
+
url: zod_default.string(),
|
|
15443
|
+
/** @deprecated Unread by any client — resolve bytes through `url` or `id`. */
|
|
15444
|
+
file_storage_key: zod_default.string(),
|
|
15445
|
+
mime_type: zod_default.string(),
|
|
15446
|
+
created_at: zod_default.string(),
|
|
15447
|
+
is_current: zod_default.boolean(),
|
|
15448
|
+
source_file_id: zod_default.string().nullable()
|
|
15449
|
+
});
|
|
15450
|
+
var fileVersionListResponseSchema = zod_default.object({
|
|
15451
|
+
original_file_id: zod_default.string(),
|
|
15452
|
+
current_version_id: zod_default.string(),
|
|
15453
|
+
versions: zod_default.array(fileVersionEntrySchema).describe("Ordered newest \u2192 oldest. Index 0 is the current version.")
|
|
15454
|
+
});
|
|
15455
|
+
|
|
15264
15456
|
// ../../node_modules/date-fns/constants.js
|
|
15265
15457
|
var daysInYear = 365.2425;
|
|
15266
15458
|
var maxTime = Math.pow(10, 8) * 24 * 60 * 60 * 1e3;
|
|
@@ -15812,7 +16004,7 @@ function aggregateByField(records, fieldType, option, subject) {
|
|
|
15812
16004
|
operation
|
|
15813
16005
|
);
|
|
15814
16006
|
case "files":
|
|
15815
|
-
return aggregateGenericField(values2, operation);
|
|
16007
|
+
return aggregateGenericField(values2.map((value) => Array.isArray(value) ? storedFileIds(value) : value), operation);
|
|
15816
16008
|
case "rollup":
|
|
15817
16009
|
return aggregateRollupField(
|
|
15818
16010
|
values2,
|
|
@@ -27401,9 +27593,6 @@ function lookupElementToString(element) {
|
|
|
27401
27593
|
if (element.length > 0 && typeof element[0] === "object" && element[0] !== null && "display" in element[0]) {
|
|
27402
27594
|
return element.map((item) => item.display).filter(Boolean).join(", ");
|
|
27403
27595
|
}
|
|
27404
|
-
if (element.length > 0 && typeof element[0] === "object" && element[0] !== null && "filename" in element[0]) {
|
|
27405
|
-
return element.map((file2) => file2.filename).filter(Boolean).join(", ");
|
|
27406
|
-
}
|
|
27407
27596
|
return element.filter(Boolean).join(", ");
|
|
27408
27597
|
}
|
|
27409
27598
|
return String(element);
|
|
@@ -27453,6 +27642,10 @@ function getFieldDisplayValue(field, value) {
|
|
|
27453
27642
|
case "lookup": {
|
|
27454
27643
|
if (typeof value === "string") return value;
|
|
27455
27644
|
if (!Array.isArray(value)) return String(value);
|
|
27645
|
+
if (field.lookup_field_type === "files") {
|
|
27646
|
+
const count = cellFileIds(value).length;
|
|
27647
|
+
return count === 0 ? "" : `${count} file(s)`;
|
|
27648
|
+
}
|
|
27456
27649
|
return value.map(lookupElementToString).filter(Boolean).join(", ");
|
|
27457
27650
|
}
|
|
27458
27651
|
case "autonumber":
|
|
@@ -29015,7 +29208,16 @@ function filterTraversalPaths(filter2) {
|
|
|
29015
29208
|
visit(filter2);
|
|
29016
29209
|
return paths;
|
|
29017
29210
|
}
|
|
29018
|
-
|
|
29211
|
+
var FileNamesUnavailableError = class extends Error {
|
|
29212
|
+
constructor(fieldKey, operator) {
|
|
29213
|
+
super(
|
|
29214
|
+
`Filter on "${fieldKey}": '${operator}' reads a file's name or type, and the cell names its files by id \u2014 the files' rows must be supplied to evaluate it.`
|
|
29215
|
+
);
|
|
29216
|
+
this.name = "FileNamesUnavailableError";
|
|
29217
|
+
}
|
|
29218
|
+
};
|
|
29219
|
+
var FILE_NAME_OPERATORS = /* @__PURE__ */ new Set(["has_filename", "has_mime_type"]);
|
|
29220
|
+
function filterRecords(records, fields, filters, memberContext, traversalContext, fileFacts) {
|
|
29019
29221
|
if (!filters) {
|
|
29020
29222
|
return records;
|
|
29021
29223
|
}
|
|
@@ -29027,23 +29229,23 @@ function filterRecords(records, fields, filters, memberContext, traversalContext
|
|
|
29027
29229
|
fieldByKey[field.key] = field;
|
|
29028
29230
|
});
|
|
29029
29231
|
return records.filter(
|
|
29030
|
-
(record2) => filterRecord(record2, fieldByKey, filters, memberContext, traversalContext)
|
|
29232
|
+
(record2) => filterRecord(record2, fieldByKey, filters, memberContext, traversalContext, fileFacts)
|
|
29031
29233
|
);
|
|
29032
29234
|
}
|
|
29033
|
-
function filterRecord(record2, fields, filters, memberContext, traversalContext) {
|
|
29235
|
+
function filterRecord(record2, fields, filters, memberContext, traversalContext, fileFacts) {
|
|
29034
29236
|
if (!filters) {
|
|
29035
29237
|
return true;
|
|
29036
29238
|
}
|
|
29037
29239
|
if (filters.node_type !== "group") {
|
|
29038
|
-
const result = evaluateFilterNode(record2, fields, filters, memberContext, traversalContext);
|
|
29240
|
+
const result = evaluateFilterNode(record2, fields, filters, memberContext, traversalContext, fileFacts);
|
|
29039
29241
|
return result ?? true;
|
|
29040
29242
|
}
|
|
29041
29243
|
if (!filters.children || filters.children.length === 0) {
|
|
29042
29244
|
return true;
|
|
29043
29245
|
}
|
|
29044
|
-
return evaluateFilterNode(record2, fields, filters, memberContext, traversalContext);
|
|
29246
|
+
return evaluateFilterNode(record2, fields, filters, memberContext, traversalContext, fileFacts);
|
|
29045
29247
|
}
|
|
29046
|
-
function evaluateFilterNode(record2, fields, node, memberContext, traversalContext) {
|
|
29248
|
+
function evaluateFilterNode(record2, fields, node, memberContext, traversalContext, fileFacts) {
|
|
29047
29249
|
if (node.node_type === "group") {
|
|
29048
29250
|
const evaluatedResults = [];
|
|
29049
29251
|
for (const child of node.children) {
|
|
@@ -29052,7 +29254,8 @@ function evaluateFilterNode(record2, fields, node, memberContext, traversalConte
|
|
|
29052
29254
|
fields,
|
|
29053
29255
|
child,
|
|
29054
29256
|
memberContext,
|
|
29055
|
-
traversalContext
|
|
29257
|
+
traversalContext,
|
|
29258
|
+
fileFacts
|
|
29056
29259
|
);
|
|
29057
29260
|
if (childResult !== null) {
|
|
29058
29261
|
evaluatedResults.push(childResult);
|
|
@@ -29063,14 +29266,14 @@ function evaluateFilterNode(record2, fields, node, memberContext, traversalConte
|
|
|
29063
29266
|
}
|
|
29064
29267
|
return node.logic === "and" ? evaluatedResults.every((result) => result) : evaluatedResults.some((result) => result);
|
|
29065
29268
|
} else if (node.node_type === "traversal") {
|
|
29066
|
-
const result = evaluateTraversal(record2, fields, node, memberContext, traversalContext);
|
|
29269
|
+
const result = evaluateTraversal(record2, fields, node, memberContext, traversalContext, fileFacts);
|
|
29067
29270
|
return result ?? true;
|
|
29068
29271
|
} else {
|
|
29069
|
-
const result = evaluateCondition(record2, fields, node, memberContext);
|
|
29272
|
+
const result = evaluateCondition(record2, fields, node, memberContext, fileFacts);
|
|
29070
29273
|
return result ?? true;
|
|
29071
29274
|
}
|
|
29072
29275
|
}
|
|
29073
|
-
function evaluateTraversal(record2, fields, node, memberContext, traversalContext) {
|
|
29276
|
+
function evaluateTraversal(record2, fields, node, memberContext, traversalContext, fileFacts) {
|
|
29074
29277
|
if (node.condition.type !== "locked" && node.condition.type !== "current_member" && (typeof node.condition.field_key !== "string" || !node.condition.field_key)) {
|
|
29075
29278
|
return null;
|
|
29076
29279
|
}
|
|
@@ -29098,12 +29301,12 @@ function evaluateTraversal(record2, fields, node, memberContext, traversalContex
|
|
|
29098
29301
|
if (currentRecords.length === 0) return true;
|
|
29099
29302
|
const positiveCondition = { ...inner, operator: positiveOp };
|
|
29100
29303
|
return !currentRecords.some(
|
|
29101
|
-
(r) => evaluateCondition(r, currentFields, positiveCondition, memberContext) === true
|
|
29304
|
+
(r) => evaluateCondition(r, currentFields, positiveCondition, memberContext, fileFacts) === true
|
|
29102
29305
|
);
|
|
29103
29306
|
}
|
|
29104
29307
|
if (currentRecords.length === 0) return false;
|
|
29105
29308
|
return currentRecords.some(
|
|
29106
|
-
(r) => evaluateCondition(r, currentFields, inner, memberContext) === true
|
|
29309
|
+
(r) => evaluateCondition(r, currentFields, inner, memberContext, fileFacts) === true
|
|
29107
29310
|
);
|
|
29108
29311
|
}
|
|
29109
29312
|
function requireMemberContext(memberContext, operator) {
|
|
@@ -29114,7 +29317,7 @@ function requireMemberContext(memberContext, operator) {
|
|
|
29114
29317
|
}
|
|
29115
29318
|
return memberContext;
|
|
29116
29319
|
}
|
|
29117
|
-
function evaluateCondition(record2, fieldByKey, looseCondition, memberContext) {
|
|
29320
|
+
function evaluateCondition(record2, fieldByKey, looseCondition, memberContext, fileFacts) {
|
|
29118
29321
|
if (looseCondition.type === "locked") {
|
|
29119
29322
|
const locked = record2.locked;
|
|
29120
29323
|
if (locked === void 0) return null;
|
|
@@ -29153,26 +29356,26 @@ function evaluateCondition(record2, fieldByKey, looseCondition, memberContext) {
|
|
|
29153
29356
|
const factor = rowUnitFactor(reading.unit, data);
|
|
29154
29357
|
if (factor === void 0) return false;
|
|
29155
29358
|
const inBase = { ...filterCondition, value: filterCondition.value * reading.unit.factor };
|
|
29156
|
-
return matchCell(columnValue, field, inBase, looseCondition, memberContext, factor);
|
|
29359
|
+
return matchCell(columnValue, field, inBase, looseCondition, memberContext, fileFacts, factor);
|
|
29157
29360
|
}
|
|
29158
29361
|
}
|
|
29159
29362
|
if (field?.type === "lookup" && (Array.isArray(columnValue) || columnValue == null)) {
|
|
29160
29363
|
const values2 = Array.isArray(columnValue) ? columnValue : [];
|
|
29161
|
-
return matchLookup(values2, field, filterCondition, looseCondition, memberContext);
|
|
29364
|
+
return matchLookup(values2, field, filterCondition, looseCondition, memberContext, fileFacts);
|
|
29162
29365
|
}
|
|
29163
|
-
return matchCell(columnValue, field, filterCondition, looseCondition, memberContext);
|
|
29366
|
+
return matchCell(columnValue, field, filterCondition, looseCondition, memberContext, fileFacts);
|
|
29164
29367
|
}
|
|
29165
29368
|
function flattenLookupValues(values2) {
|
|
29166
29369
|
return values2.flatMap((value) => Array.isArray(value) ? flattenLookupValues(value) : [value]);
|
|
29167
29370
|
}
|
|
29168
|
-
function matchLookup(cell, field, filterCondition, looseCondition, memberContext) {
|
|
29371
|
+
function matchLookup(cell, field, filterCondition, looseCondition, memberContext, fileFacts) {
|
|
29169
29372
|
switch (filterCondition.type) {
|
|
29170
29373
|
case "select":
|
|
29171
29374
|
case "select_member":
|
|
29172
29375
|
case "select_record_link":
|
|
29173
29376
|
case "files": {
|
|
29174
29377
|
const union2 = cell.flatMap((list2) => Array.isArray(list2) ? list2 : []);
|
|
29175
|
-
return matchCell(union2, field, filterCondition, looseCondition, memberContext);
|
|
29378
|
+
return matchCell(union2, field, filterCondition, looseCondition, memberContext, fileFacts);
|
|
29176
29379
|
}
|
|
29177
29380
|
case "text":
|
|
29178
29381
|
case "number":
|
|
@@ -29192,7 +29395,7 @@ function matchLookup(cell, field, filterCondition, looseCondition, memberContext
|
|
|
29192
29395
|
let anyMatched = false;
|
|
29193
29396
|
for (const value of values2) {
|
|
29194
29397
|
const element = filterCondition.type === "text" && typeof value !== "string" && value !== null && value !== void 0 ? String(value) : value;
|
|
29195
|
-
const result = matchCell(element, field, positive, looseCondition, memberContext);
|
|
29398
|
+
const result = matchCell(element, field, positive, looseCondition, memberContext, fileFacts);
|
|
29196
29399
|
if (result !== null) allSkipped = false;
|
|
29197
29400
|
if (result === true) {
|
|
29198
29401
|
anyMatched = true;
|
|
@@ -29202,7 +29405,16 @@ function matchLookup(cell, field, filterCondition, looseCondition, memberContext
|
|
|
29202
29405
|
if (values2.length > 0 && allSkipped) return null;
|
|
29203
29406
|
return positiveOperator ? !anyMatched : anyMatched;
|
|
29204
29407
|
}
|
|
29205
|
-
function
|
|
29408
|
+
function fileProperty(entry, property, fileFacts, fieldKey, operator) {
|
|
29409
|
+
if (fileFacts) {
|
|
29410
|
+
const [id] = storedFileIds([entry]);
|
|
29411
|
+
return id === void 0 ? "" : fileFacts.get(id)?.[property] ?? "";
|
|
29412
|
+
}
|
|
29413
|
+
if (typeof entry === "string") throw new FileNamesUnavailableError(fieldKey, operator);
|
|
29414
|
+
const own = typeof entry === "object" && entry !== null ? Reflect.get(entry, property) : void 0;
|
|
29415
|
+
return typeof own === "string" ? own : "";
|
|
29416
|
+
}
|
|
29417
|
+
function matchCell(cell, field, filterCondition, looseCondition, memberContext, fileFacts, figureFactor = 1) {
|
|
29206
29418
|
let columnValue = cell;
|
|
29207
29419
|
const resolvedType = filterCondition.type;
|
|
29208
29420
|
const fieldKey = filterCondition.field_key;
|
|
@@ -29574,7 +29786,7 @@ function matchCell(cell, field, filterCondition, looseCondition, memberContext,
|
|
|
29574
29786
|
if (typeof filterCondition.value !== "string") return null;
|
|
29575
29787
|
const term = filterCondition.value.trim().toLowerCase();
|
|
29576
29788
|
if (!term) return null;
|
|
29577
|
-
matches = files !== null && files.some((
|
|
29789
|
+
matches = files !== null && files.some((entry) => fileProperty(entry, property, fileFacts, fieldKey, filterCondition.operator).toLowerCase().includes(term));
|
|
29578
29790
|
}
|
|
29579
29791
|
} else {
|
|
29580
29792
|
const op = typeof looseCondition.operator === "string" ? looseCondition.operator : "(unset)";
|
|
@@ -29593,6 +29805,7 @@ function condition(node, subject) {
|
|
|
29593
29805
|
if (node.field_key === void 0 || node.unit_option !== void 0) return "unknown";
|
|
29594
29806
|
if (typeof node.value === "string" && node.value.includes("{{")) return "unknown";
|
|
29595
29807
|
if (BY_VIEWER.has(node.operator) && subject.member_id === null) return "unknown";
|
|
29808
|
+
if (FILE_NAME_OPERATORS.has(node.operator)) return "unknown";
|
|
29596
29809
|
const field = subject.field(node.field_key);
|
|
29597
29810
|
const held2 = subject.cell(node.field_key);
|
|
29598
29811
|
if (field === void 0 || held2 === void 0) return "unknown";
|
|
@@ -29845,7 +30058,7 @@ function prepareFormulaContext(fields) {
|
|
|
29845
30058
|
nonFormulaFields
|
|
29846
30059
|
};
|
|
29847
30060
|
}
|
|
29848
|
-
function computeFormulaFieldsWithContext(row2, fields, context, linkedTableRecords = /* @__PURE__ */ new Map(), linkedTableFields = /* @__PURE__ */ new Map(), timezone, loadedLinkKeys, linkExpandedTableIds) {
|
|
30061
|
+
function computeFormulaFieldsWithContext(row2, fields, context, linkedTableRecords = /* @__PURE__ */ new Map(), linkedTableFields = /* @__PURE__ */ new Map(), timezone, loadedLinkKeys, linkExpandedTableIds, fileFacts) {
|
|
29849
30062
|
const result = { ...row2 };
|
|
29850
30063
|
const mayRecompute = (linkKey) => loadedLinkKeys === void 0 || loadedLinkKeys.has(linkKey) || isEmptyFieldValue(row2[linkKey]);
|
|
29851
30064
|
for (const { field, sourceField } of context.rollupFields) {
|
|
@@ -29890,7 +30103,8 @@ function computeFormulaFieldsWithContext(row2, fields, context, linkedTableRecor
|
|
|
29890
30103
|
definition: `Rollup "${field.name}" (${field.key})`,
|
|
29891
30104
|
source: `"${linkedFieldType.name}" (${linkedFieldType.key})`
|
|
29892
30105
|
} : void 0,
|
|
29893
|
-
traversal.context
|
|
30106
|
+
traversal.context,
|
|
30107
|
+
fileFacts
|
|
29894
30108
|
);
|
|
29895
30109
|
result[field.key] = rollupValue;
|
|
29896
30110
|
} else {
|
|
@@ -30213,13 +30427,13 @@ function planFilterTraversal(filter2, aggregatedTableId, aggregatedFields, linke
|
|
|
30213
30427
|
}
|
|
30214
30428
|
};
|
|
30215
30429
|
}
|
|
30216
|
-
function calculateRollupValue(value, fieldType, allLinkedTableRecords, aggregateOption, filter2, linkedTableFields, subject, traversalContext) {
|
|
30430
|
+
function calculateRollupValue(value, fieldType, allLinkedTableRecords, aggregateOption, filter2, linkedTableFields, subject, traversalContext, fileFacts) {
|
|
30217
30431
|
if (aggregateOption?.operation === "count") {
|
|
30218
30432
|
if (!value) return 0;
|
|
30219
30433
|
const recordIds2 = extractRecordLinkIds(value);
|
|
30220
30434
|
let relevantRecords2 = allLinkedTableRecords.filter((r) => recordIds2.includes(r.id));
|
|
30221
30435
|
if (filter2 && linkedTableFields) {
|
|
30222
|
-
relevantRecords2 = filterRecords(relevantRecords2, linkedTableFields, filter2, null, traversalContext);
|
|
30436
|
+
relevantRecords2 = filterRecords(relevantRecords2, linkedTableFields, filter2, null, traversalContext, fileFacts);
|
|
30223
30437
|
}
|
|
30224
30438
|
return relevantRecords2.length;
|
|
30225
30439
|
}
|
|
@@ -30232,7 +30446,7 @@ function calculateRollupValue(value, fieldType, allLinkedTableRecords, aggregate
|
|
|
30232
30446
|
return null;
|
|
30233
30447
|
}
|
|
30234
30448
|
if (filter2 && linkedTableFields) {
|
|
30235
|
-
relevantRecords = filterRecords(relevantRecords, linkedTableFields, filter2, null, traversalContext);
|
|
30449
|
+
relevantRecords = filterRecords(relevantRecords, linkedTableFields, filter2, null, traversalContext, fileFacts);
|
|
30236
30450
|
}
|
|
30237
30451
|
if (relevantRecords.length === 0) {
|
|
30238
30452
|
return null;
|
|
@@ -30761,13 +30975,25 @@ function pageOf(mocked, call) {
|
|
|
30761
30975
|
function countOf(mocked, by) {
|
|
30762
30976
|
return { total: mocked.length, ...by === void 0 ? {} : { counts: countsOf(mocked, by) } };
|
|
30763
30977
|
}
|
|
30978
|
+
function within(filter2, named) {
|
|
30979
|
+
return filter2 === void 0 ? named : { node_type: "group", logic: "and", children: [filter2, named] };
|
|
30980
|
+
}
|
|
30981
|
+
function mockCount(alias, call) {
|
|
30982
|
+
const mocked = getMockRows(alias, { params: call.params, filter: call.filter });
|
|
30983
|
+
if (mocked === null) return null;
|
|
30984
|
+
const where = Object.entries(call.where ?? {});
|
|
30985
|
+
const named = where.map(([, one]) => getMockRows(alias, { params: call.params, filter: within(call.filter, one) }) ?? []);
|
|
30986
|
+
const answer = (rows, each) => call.where === void 0 ? countOf(rows, call.by) : { total: rows.length, counts: Object.fromEntries(where.map(([name], at2) => [name, each[at2]?.length ?? 0])) };
|
|
30987
|
+
if (Array.isArray(mocked) && named.every((one) => Array.isArray(one))) return answer(mocked, named.filter((one) => Array.isArray(one)));
|
|
30988
|
+
return Promise.all([mocked, ...named]).then(([rows = [], ...each]) => answer(rows, each));
|
|
30989
|
+
}
|
|
30764
30990
|
function knownRows(alias, call) {
|
|
30765
30991
|
const mocked = getMockRows(alias, call);
|
|
30766
30992
|
return Array.isArray(mocked) ? pageOf(mocked, call) : void 0;
|
|
30767
30993
|
}
|
|
30768
30994
|
function knownCount(alias, call) {
|
|
30769
|
-
const
|
|
30770
|
-
return
|
|
30995
|
+
const counted = mockCount(alias, call);
|
|
30996
|
+
return counted === null || counted instanceof Promise ? void 0 : counted;
|
|
30771
30997
|
}
|
|
30772
30998
|
async function askRows(key, alias, askedAt, call, since = askedAt) {
|
|
30773
30999
|
const mocked = getMockRows(alias, call);
|
|
@@ -30784,18 +31010,22 @@ async function askRows(key, alias, askedAt, call, since = askedAt) {
|
|
|
30784
31010
|
return answer;
|
|
30785
31011
|
}
|
|
30786
31012
|
async function askCount(key, alias, askedAt, call) {
|
|
30787
|
-
const mocked =
|
|
30788
|
-
if (mocked !== null) return
|
|
31013
|
+
const mocked = mockCount(alias, call);
|
|
31014
|
+
if (mocked !== null) return mocked;
|
|
30789
31015
|
const answer = await rpc("query", {
|
|
30790
31016
|
alias,
|
|
30791
31017
|
params: call.params,
|
|
30792
31018
|
count: true,
|
|
30793
31019
|
...call.filter === void 0 ? {} : { filter: call.filter },
|
|
30794
|
-
...call.by === void 0 ? {} : { count_by: call.by }
|
|
31020
|
+
...call.by === void 0 ? {} : { count_by: call.by },
|
|
31021
|
+
...call.where === void 0 ? {} : { count_where: call.where }
|
|
30795
31022
|
});
|
|
30796
31023
|
noteAnswer(key, alias, askedAt, []);
|
|
30797
31024
|
return answer;
|
|
30798
31025
|
}
|
|
31026
|
+
function namedCounts(named) {
|
|
31027
|
+
return { rows: Object.fromEntries(named.map(([name, one]) => [name, one.rows])), pending: named.some(([, one]) => one.pending) };
|
|
31028
|
+
}
|
|
30799
31029
|
var feedWants = /* @__PURE__ */ new Map();
|
|
30800
31030
|
function ignore() {
|
|
30801
31031
|
}
|
|
@@ -30808,7 +31038,8 @@ function useQuery(alias, params = {}, opts = {}) {
|
|
|
30808
31038
|
const filter2 = opts.filter;
|
|
30809
31039
|
const wantsRows = opts.rows ?? true;
|
|
30810
31040
|
const totalAsked = opts.total ?? opts.page !== void 0;
|
|
30811
|
-
const by = typeof totalAsked === "object" ? totalAsked.by : void 0;
|
|
31041
|
+
const by = typeof totalAsked === "object" && "by" in totalAsked ? totalAsked.by : void 0;
|
|
31042
|
+
const where = typeof totalAsked === "object" && "where" in totalAsked ? totalAsked.where : void 0;
|
|
30812
31043
|
const call = JSON.stringify([params, filter2 ?? null, sort ?? null]);
|
|
30813
31044
|
const mocking = hasMockFlag();
|
|
30814
31045
|
const [paging, setPaging] = useState3({ call, page: 0 });
|
|
@@ -30863,11 +31094,12 @@ function useQuery(alias, params = {}, opts = {}) {
|
|
|
30863
31094
|
feedWants.set(feedKey, heldPages + 1);
|
|
30864
31095
|
feed.refetch();
|
|
30865
31096
|
}, [feedKey, feedHasMore, heldPages, feed]);
|
|
30866
|
-
const countKey = !enabled || totalAsked === false ? null : JSON.stringify(["count", alias, JSON.stringify([params, filter2 ?? null]), by ?? null]);
|
|
30867
|
-
const
|
|
31097
|
+
const countKey = !enabled || totalAsked === false ? null : JSON.stringify(["count", alias, JSON.stringify([params, filter2 ?? null]), by ?? null, where ?? null]);
|
|
31098
|
+
const countCall = { params, ...filter2 === void 0 ? {} : { filter: filter2 }, ...by === void 0 ? {} : { by }, ...where === void 0 ? {} : { where } };
|
|
31099
|
+
const count = useKey(countKey, (askedAt) => askCount(countKey ?? "", alias, askedAt, countCall), {
|
|
30868
31100
|
focus,
|
|
30869
31101
|
aliases: [alias],
|
|
30870
|
-
known: mocking ? () => knownCount(alias,
|
|
31102
|
+
known: mocking ? () => knownCount(alias, countCall) : void 0
|
|
30871
31103
|
});
|
|
30872
31104
|
const version3 = useWrittenVersion();
|
|
30873
31105
|
const drawn2 = useMemo3(() => {
|
|
@@ -30883,9 +31115,10 @@ function useQuery(alias, params = {}, opts = {}) {
|
|
|
30883
31115
|
const counted = useMemo3(() => {
|
|
30884
31116
|
if (count.data === void 0) return void 0;
|
|
30885
31117
|
const total2 = overlayCount(alias, params, filter2, count.askedAt, count.data.total);
|
|
30886
|
-
const
|
|
31118
|
+
const answered2 = count.data.counts;
|
|
31119
|
+
const counts = answered2 === void 0 ? void 0 : where !== void 0 ? namedCounts(Object.entries(where).map(([name, one]) => [name, overlayCount(alias, params, within(filter2, one), count.askedAt, answered2[name] ?? 0)])) : by === void 0 ? void 0 : overlayCounts(alias, params, filter2, by, count.askedAt, answered2);
|
|
30887
31120
|
return { total: total2.rows, counts: counts?.rows, pending: total2.pending || (counts?.pending ?? false) };
|
|
30888
|
-
}, [alias, call,
|
|
31121
|
+
}, [alias, call, countKey, count.data, count.askedAt, version3]);
|
|
30889
31122
|
const total = counted?.total;
|
|
30890
31123
|
const pageCount = opts.page === void 0 || total === void 0 ? void 0 : Math.max(1, Math.ceil(total / opts.page));
|
|
30891
31124
|
const pageHasMore = opts.page === void 0 ? false : total !== void 0 ? (page + 1) * opts.page < total : drawn2.rows.length === opts.page;
|
package/dist/queries.d.ts
CHANGED
|
@@ -89,10 +89,13 @@ export interface QueryOptions<C extends string = string> {
|
|
|
89
89
|
more?: number;
|
|
90
90
|
/**
|
|
91
91
|
* The total the rows come to — a count of the whole set, its own read, so the rows never wait for it —
|
|
92
|
-
*
|
|
92
|
+
* with `by` a count per value of that column from the same scan, or with `where` a count per named filter
|
|
93
|
+
* of the rows it keeps within the set, every one from the same scan. On by default with `page`.
|
|
93
94
|
*/
|
|
94
95
|
total?: boolean | {
|
|
95
96
|
by: C;
|
|
97
|
+
} | {
|
|
98
|
+
where: Readonly<Record<string, QueryFilter<C>>>;
|
|
96
99
|
};
|
|
97
100
|
/** `false` reads no rows: a total alone. Default `true`. */
|
|
98
101
|
rows?: boolean;
|
|
@@ -103,7 +106,8 @@ export interface QueryState<R> {
|
|
|
103
106
|
truncated: boolean;
|
|
104
107
|
/** The whole set's count, where `total` is asked; `undefined` until it answers. */
|
|
105
108
|
total: number | undefined;
|
|
106
|
-
/** Rows per value of `total.by`, keyed as a row carries it (an option's `opt_…`)
|
|
109
|
+
/** Rows per value of `total.by`, keyed as a row carries it (an option's `opt_…`), a value no row holds absent;
|
|
110
|
+
* or per name of `total.where`. */
|
|
107
111
|
counts: Readonly<Record<string, number>> | undefined;
|
|
108
112
|
/** Only the first read of a key with nothing to show; a re-read and a new key keep the rows on screen. */
|
|
109
113
|
loading: boolean;
|
package/docs/data_fetching.md
CHANGED
|
@@ -21,7 +21,7 @@ untyped. It is written from the app's live bindings by `lotics app create --cust
|
|
|
21
21
|
| `useQuery(alias, params?, opts?)` | `rows` up to the server's cap, or the first `limit` | a detail read, a dashboard block, a combobox's top-N — anything that is not a long list |
|
|
22
22
|
| `useQuery(alias, params?, { page: n })` | one numbered page of `n` rows, `total`, `pageCount`, `setPage` | numbered, jumpable pages |
|
|
23
23
|
| `useQuery(alias, params?, { more: n })` | accumulated `rows` + `loadMore` | infinite scroll / "load more" feeds |
|
|
24
|
-
| `useQuery(alias, params?, { rows: false, total: true })` | `total` only, no rows (`total: { by: column }` adds `counts`) | a facet chip, a queue badge, an "N awaiting approval" tile — the size of a set you are not listing |
|
|
24
|
+
| `useQuery(alias, params?, { rows: false, total: true })` | `total` only, no rows (`total: { by: column }` or `total: { where: { name: filter } }` adds `counts`) | a facet chip, a queue badge, an "N awaiting approval" tile — the size of a set you are not listing |
|
|
25
25
|
| `useQueries(calls, opts?)` | one state per `{ alias, params?, filter?, sort?, aggregate?, total?, rows? }`, in order — a `sort` orders that call's rows before the cap cuts them | reads that are DATA — a list known only at render, one read per item, or one count per stage (`rows: false, total: true`, each state's `.total`); the deploy's alias scan reads a list as dynamic, so every alias in it is still declared. With `aggregate` a call answers the groups of its filtered rows instead of the rows ([queries](./queries.md)) |
|
|
26
26
|
| `queryAll(alias, params?, { filter, sort })` | a promise of every row | outside React, for a job that must hold the whole narrowed set in the browser (the ids an act runs on): it asks page after page until the server says the set ended, never stopping at the 10,000-row cap. The server's rows as stored — no write of this app is drawn over them |
|
|
27
27
|
| `exportQuery(alias, { params, filter, sort, tabs?, report, file })` | a promise of the file | every row the narrowed set holds, made into one file by the server — no row reaches the browser. `tabs` (2 to 5, each `{ tab, label, filter?, sort? }`) are each read as the export's own rows are — same query and params, under the tab's own filter and sort (absent, the declared order) — and a template reads each one's rows under its `tab`, a name none of the report's keys nor the template's `rows` key holds. `report` is `{ title, lines, readings, dates?, period?, per?, filename? }` — `dates` each date filter's `{ from?, to? }` by field alias, each bound a day (`yyyy-MM-dd`) or a minute of one (`yyyy-MM-ddTHH:mm`) with no zone; `period` the tabs' `{ from, to }`, bounds of the same form; `per` the value the report was picked for, in the reader's words; `filename` the file's name in the template grammar over one value each — `title`, `at` (the moment the server made the file), `dates.<field>.from` or `.to`, `period.from` or `.to`, `per`, `lines | lookup:<n>` — at most 200 bytes once filled (absent, the template's name, or `title`); `file` is `{ kind: "workbook", language, sheet, columns }` (the default report workbook, its head closing with the moment the server made it, each column `{ key, header, type }` an output column of the query — a `number` one on a number column, a `date` one on a date or datetime; with `tabs`, one sheet per tab named by its `label` in place of `sheet`) or `{ kind: "template", template }`, one the query declares under `templates` by name ([queries](./queries.md)). Past 20,000 rows, in any tab, it rejects, never cuts; each caller makes at most 60 exports a minute. The file's `url` is signed for the caller: open it with `openExternal` |
|
|
@@ -63,7 +63,15 @@ const { total, counts } = useQuery("orders", { q }, { rows: false, total: { by:
|
|
|
63
63
|
// counts?.["opt_shipped"] — the rows in that stage over the whole set, however few are loaded
|
|
64
64
|
```
|
|
65
65
|
|
|
66
|
-
|
|
66
|
+
Several counts of one set — a tab each, a share each — are **one read with `total: { where }`**: each
|
|
67
|
+
named filter's rows within the read's `filter`, as `counts` by name, from the same scan.
|
|
68
|
+
|
|
69
|
+
```tsx
|
|
70
|
+
const { total, counts } = useQuery("orders", {}, { rows: false, total: { where: { late: lateFilter, mine: mineFilter } } });
|
|
71
|
+
// counts?.late, counts?.mine — one scan, however many names
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
A `useQueries` call's `total` is `true` or absent — it takes no `by` or `where`.
|
|
67
75
|
|
|
68
76
|
### Why not hand-roll it
|
|
69
77
|
|
|
@@ -321,15 +321,14 @@ updateComment, deleteComment, refetch }`.
|
|
|
321
321
|
- `comments` — newest first on the wire (server order); a thread usually reads oldest-first, so
|
|
322
322
|
reverse it for display. Each `AppComment`: `{ id, record_id,
|
|
323
323
|
table_id, member_id, author, content, files, workspace_id, created_at, updated_at }`. Attachments
|
|
324
|
-
(`AppCommentFile`) carry `id` / `filename` / `mime_type`
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
`Timeline`'s default file row does), never by counting on a fetchable URL.
|
|
324
|
+
(`AppCommentFile`) carry `id` / `filename` / `mime_type` and a presigned `url` (plus
|
|
325
|
+
`thumbnail_url` for an image), valid 24 hours and refreshed by every read. A file's identity is
|
|
326
|
+
its `id`: the server reads every attachment from the file by that id, so nothing else you hold
|
|
327
|
+
about a file can affect what is stored.
|
|
329
328
|
- `createComment({ content, file_ids? })` — posts as the viewing member. `file_ids` come from
|
|
330
329
|
`useFileUpload` / `useAttachments` (see [files](./files.md)). A comment must have content or at
|
|
331
|
-
least one file (empty input is a client no-op; the server enforces the same rule).
|
|
332
|
-
10,000 characters.
|
|
330
|
+
least one file (empty input is a client no-op; the server enforces the same rule). A file id
|
|
331
|
+
that names no live file in this workspace refuses the comment. Content max 10,000 characters.
|
|
333
332
|
- `updateComment(id, { content, files? })` — omit `files` to keep the current attachment set (a
|
|
334
333
|
text-only edit never drops attachments); pass `files` to replace the whole set.
|
|
335
334
|
- All three mutations apply **optimistically** with rollback on error, then repopulate from the
|
package/docs/mutations.md
CHANGED
|
@@ -526,7 +526,7 @@ every read before the request answers:
|
|
|
526
526
|
leaves it; a created row it admits joins it, standing by the read's order (the call's `sort`,
|
|
527
527
|
then the query's own) where its sort cells are numbers or dates, else first. A row the read does
|
|
528
528
|
not hold that a write may move into it waits for the server, and `pending` says so.
|
|
529
|
-
- **Totals.** A `total`, a count per value (`total: { by }`), a group asked with `aggregate` or
|
|
529
|
+
- **Totals.** A `total`, a count per value (`total: { by }`) or per named filter (`total: { where }`), a group asked with `aggregate` or
|
|
530
530
|
declared by the query, and a parent's count or sum rollup move by exactly what the written row adds
|
|
531
531
|
or takes away; a group the last row leaves is gone, and a count per value gains the value a row
|
|
532
532
|
first holds. A mean, an extreme, a distinct count, a group by day, an aggregate with its own
|
package/docs/queries.md
CHANGED
|
@@ -844,7 +844,10 @@ template as derived nodes, in this order: `filter` (narrow) → `sort` (order)
|
|
|
844
844
|
- **`count: true`** — returns `{ total }` only: a COUNT over the *filtered* set, ignoring
|
|
845
845
|
sort/limit/offset. Drives "Page 1 of N". With **`count_by: "<column>"`** the same scan also
|
|
846
846
|
returns `counts` — rows per value of that projected column (a multi-value row under each value,
|
|
847
|
-
a row holding none in `total` only); a column whose values are not plain (a link) → 400.
|
|
847
|
+
a row holding none in `total` only); a column whose values are not plain (a link) → 400. With
|
|
848
|
+
**`count_where: { <name>: <filter> }`** (not beside `count_by`) the same scan returns `counts` by
|
|
849
|
+
name — the rows each filter keeps within `filter`, each filter bounded and resolved as `filter` is,
|
|
850
|
+
at most 32. It is a
|
|
848
851
|
**second full execution** of the template — sent only where a read asks for its `total`, and
|
|
849
852
|
one count serves every read of the same set ([data_fetching](./data_fetching.md)).
|
|
850
853
|
- **`aggregate: { by?, day?, sum? }`** — the groups of the *filtered* set in place of its rows: one
|
package/docs/security.md
CHANGED
|
@@ -111,7 +111,7 @@ Listing members (`useMembers`) takes an authenticated member of the app's own or
|
|
|
111
111
|
|
|
112
112
|
## What runtime refinement cannot widen
|
|
113
113
|
|
|
114
|
-
The query RPC accepts runtime `filter` and `sort` (for search boxes, sortable tables, pickers) — but these are **bounded to the named query's output columns**. A `field_key` naming a column the query does not project is rejected, so a caller can never filter or sort by — and thereby probe — a field the author didn't expose. The caller's `limit` is clamped to the server row cap, params fill the template's *value holes* only — filter values and the search term; tables, joins, and projections are author-fixed — and `count` mode returns a total — with `count_by`, one per value of a projected column — over the same bounded filter. Full mechanics in [queries](./queries.md).
|
|
114
|
+
The query RPC accepts runtime `filter` and `sort` (for search boxes, sortable tables, pickers) — but these are **bounded to the named query's output columns**. A `field_key` naming a column the query does not project is rejected, so a caller can never filter or sort by — and thereby probe — a field the author didn't expose. The caller's `limit` is clamped to the server row cap, params fill the template's *value holes* only — filter values and the search term; tables, joins, and projections are author-fixed — and `count` mode returns a total — with `count_by`, one per value of a projected column; with `count_where`, one per named filter, each bounded as `filter` is — over the same bounded filter. Full mechanics in [queries](./queries.md).
|
|
115
115
|
|
|
116
116
|
**Warning — templated free-text search is not output-bounded.** A `search` term in the query template (typically a `{{params.q}}` hole) matches against the record's **whole search document**: every searchable field of the table — text, numbers, dates (in three formats), select option names, member names, linked-record display text, formula/rollup/lookup values, and autonumbers (only booleans, buttons, and file fields are excluded). It is *not* restricted to the columns the query projects. A caller who controls the search term can therefore probe the *contents* of unprojected fields by watching which rows match — a row-membership oracle. Put a `search` hole only in queries over tables where every searchable field is acceptable to probe for that audience; for a search box over a table with sensitive unprojected fields, use runtime `filter` with `contains` on the projected columns instead, or AND the `search` with a template `contains` OR-group over the fields that may be probed — every row that group matches also matches the search, so the pair answers only for those fields.
|
|
117
117
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lotics/app-sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.110.0",
|
|
4
4
|
"description": "The SDK a Lotics custom-code app reads and writes through \u2014 typed hooks over the host bridge, cell readers, mount() and AppRouter",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|