The Coline App SDK: apps, tools, file types and surfaces that run inside Coline workspaces, and public websites with Coline Sites. TypeScript, with an in-memory test workspace. MIT.
| } from "./types"; |
| 6 |
| 7 | /** |
| 8 | * In-memory interpretation of the public collection query contract. The |
| 9 | * server compiles the same contract to SQL; the test workspace and the |
| 10 | * optimistic live view use this module so all three agree on filtering, |
| 11 | * ordering, and aggregation. |
| 12 | */ |
| 13 |
| 14 | const OPERATORS = new Set([ |
| 15 | "eq", |
| 16 | "ne", |
| 17 | "gt", |
| 18 | "gte", |
| 19 | "lt", |
| 20 | "lte", |
| 21 | "contains", |
| 22 | "startsWith", |
| 23 | "in", |
| 24 | "notIn", |
| 25 | "exists", |
| 26 | ]); |
| 27 |
| 28 | function isOperatorObject(value: unknown): value is Record<string, unknown> { |
| 29 | return ( |
| 30 | typeof value === "object" && |
| 31 | value !== null && |
| 32 | !Array.isArray(value) && |
| 33 | Object.keys(value).length > 0 && |
| 34 | Object.keys(value).every((key) => OPERATORS.has(key)) |
| 35 | ); |
| 36 | } |
| 37 |
| 38 | function scalarEquals(left: unknown, right: unknown): boolean { |
| 39 | if (left === undefined) left = null; |
| 40 | return left === right; |
| 41 | } |
| 42 |
| 43 | function matchesCondition(value: unknown, condition: unknown): boolean { |
| 44 | if (!isOperatorObject(condition)) return scalarEquals(value, condition); |
| 45 | return Object.entries(condition).every(([operator, expected]) => { |
| 46 | switch (operator) { |
| 47 | case "eq": |
| 48 | return scalarEquals(value, expected); |
| 49 | case "ne": |
| 50 | return !scalarEquals(value, expected); |
| 51 | case "exists": |
| 52 | return expected ? value !== undefined && value !== null : value === undefined || value === null; |
| 53 | case "in": |
| 54 | return Array.isArray(expected) && expected.some((item) => scalarEquals(value, item)); |
| 55 | case "notIn": |
| 56 | return Array.isArray(expected) && !expected.some((item) => scalarEquals(value, item)); |
| 57 | case "contains": |
| 58 | return ( |
| 59 | typeof value === "string" && |
| 60 | typeof expected === "string" && |
| 61 | value.toLowerCase().includes(expected.toLowerCase()) |
| 62 | ); |
| 63 | case "startsWith": |
| 64 | return ( |
| 65 | typeof value === "string" && |
| 66 | typeof expected === "string" && |
| 67 | value.toLowerCase().startsWith(expected.toLowerCase()) |
| 68 | ); |
| 69 | default: { |
| 70 | if ( |
| 71 | typeof value !== typeof expected || |
| 72 | (typeof value !== "string" && typeof value !== "number") |
| 73 | ) |
| 74 | return false; |
| 75 | const comparison = compareRecordValues(value, expected); |
| 76 | if (operator === "gt") return comparison > 0; |
| 77 | if (operator === "gte") return comparison >= 0; |
| 78 | if (operator === "lt") return comparison < 0; |
| 79 | if (operator === "lte") return comparison <= 0; |
| 80 | return false; |
| 81 | } |
| 82 | } |
| 83 | }); |
| 84 | } |
| 85 |
| 86 | export function matchesRecordWhere(data: Record<string, unknown>, where: unknown): boolean { |
| 87 | if (!where || typeof where !== "object") return true; |
| 88 | return Object.entries(where).every(([key, condition]) => { |
| 89 | if (key === "and") { |
| 90 | return Array.isArray(condition) && condition.every((nested) => matchesRecordWhere(data, nested)); |
| 91 | } |
| 92 | if (key === "or") { |
| 93 | return Array.isArray(condition) && condition.some((nested) => matchesRecordWhere(data, nested)); |
| 94 | } |
| 95 | return matchesCondition(data[key], condition); |
| 96 | }); |
| 97 | } |
| 98 |
| 99 | /** Numeric fields sort by value; mixed scalar types keep numbers together. */ |
| 100 | export function compareRecordValues(a: unknown, b: unknown): number { |
| 101 | if (typeof a === "number" && typeof b === "number") return a - b; |
| 102 | if (typeof a === "number") return -1; |
| 103 | if (typeof b === "number") return 1; |
| 104 | const left = String(a ?? ""), |
| 105 | right = String(b ?? ""); |
| 106 | // PostgreSQL's C collation follows Unicode code points for valid UTF-8. |
| 107 | // JavaScript's default comparison instead sorts surrogate code units. |
| 108 | let i = 0, |
| 109 | j = 0; |
| 110 | while (i < left.length && j < right.length) { |
| 111 | const leftPoint = left.codePointAt(i)!, |
| 112 | rightPoint = right.codePointAt(j)!; |
| 113 | if (leftPoint !== rightPoint) return leftPoint - rightPoint; |
| 114 | i += leftPoint > 0xffff ? 2 : 1; |
| 115 | j += rightPoint > 0xffff ? 2 : 1; |
| 116 | } |
| 117 | return left.length - right.length; |
| 118 | } |
| 119 |
| 120 | export interface NormalizedRecordSort { |
| 121 | field: string; |
| 122 | order: "asc" | "desc"; |
| 123 | } |
| 124 |
| 125 | /** The effective sort keys of a query. The default is most recently updated first. */ |
| 126 | export function normalizeRecordSort<T>( |
| 127 | query: Pick<ColineRecordQuery<T>, "orderBy" | "order"> | undefined, |
| 128 | ): NormalizedRecordSort[] { |
| 129 | const orderBy = query?.orderBy; |
| 130 | if (Array.isArray(orderBy)) { |
| 131 | return orderBy.map((sort: { field: unknown; order?: "asc" | "desc" }) => ({ |
| 132 | field: String(sort.field), |
| 133 | order: sort.order ?? "asc", |
| 134 | })); |
| 135 | } |
| 136 | if (typeof orderBy === "string") return [{ field: orderBy, order: query?.order ?? "desc" }]; |
| 137 | return [{ field: "updatedAt", order: query?.order ?? "desc" }]; |
| 138 | } |
| 139 |
| 140 | interface SortableRecord { |
| 141 | id: string; |
| 142 | createdAt: string; |
| 143 | updatedAt: string; |
| 144 | data: unknown; |
| 145 | } |
| 146 |
| 147 | function sortValue(record: SortableRecord, field: string): unknown { |
| 148 | if (field === "createdAt") return record.createdAt; |
| 149 | if (field === "updatedAt") return record.updatedAt; |
| 150 | return (record.data as Record<string, unknown>)[field]; |
| 151 | } |
| 152 |
| 153 | export function compareRecords( |
| 154 | a: SortableRecord, |
| 155 | b: SortableRecord, |
| 156 | sorts: readonly NormalizedRecordSort[], |
| 157 | ): number { |
| 158 | for (const sort of sorts) { |
| 159 | const comparison = compareRecordValues(sortValue(a, sort.field), sortValue(b, sort.field)); |
| 160 | if (comparison !== 0) return sort.order === "asc" ? comparison : -comparison; |
| 161 | } |
| 162 | const last = sorts.at(-1)?.order ?? "desc"; |
| 163 | const tie = a.id < b.id ? -1 : a.id > b.id ? 1 : 0; |
| 164 | return last === "asc" ? tie : -tie; |
| 165 | } |
| 166 |
| 167 | type GroupKeyValue = string | number | boolean | null; |
| 168 |
| 169 | function toGroupValue(value: unknown): GroupKeyValue { |
| 170 | if (value === undefined || value === null) return null; |
| 171 | if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") { |
| 172 | return value; |
| 173 | } |
| 174 | return JSON.stringify(value); |
| 175 | } |
| 176 |
| 177 | /** In-memory aggregation with the server's semantics (null for empty numeric metrics). */ |
| 178 | export function aggregateRecords<T>( |
| 179 | rows: Array<{ data: T }>, |
| 180 | input: ColineRecordAggregateInput<T>, |
| 181 | ): { groups: ColineRecordAggregateGroup[] } { |
| 182 | const groupBy = (input.groupBy ?? []).map(String); |
| 183 | const buckets = new Map<string, { key: Record<string, GroupKeyValue>; rows: Array<Record<string, unknown>> }>(); |
| 184 | for (const row of rows) { |
| 185 | const data = row.data as Record<string, unknown>; |
| 186 | if (!matchesRecordWhere(data, input.where)) continue; |
| 187 | const key: Record<string, GroupKeyValue> = {}; |
| 188 | for (const field of groupBy) key[field] = toGroupValue(data[field]); |
| 189 | const id = JSON.stringify(groupBy.map((field) => key[field])); |
| 190 | const bucket = buckets.get(id) ?? { key, rows: [] }; |
| 191 | bucket.rows.push(data); |
| 192 | buckets.set(id, bucket); |
| 193 | } |
| 194 | if (groupBy.length === 0 && buckets.size === 0) buckets.set("[]", { key: {}, rows: [] }); |
| 195 |
| 196 | const groups: ColineRecordAggregateGroup[] = [...buckets.values()].map((bucket) => { |
| 197 | const values: Record<string, number | null> = {}; |
| 198 | for (const [name, metric] of Object.entries(input.metrics)) { |
| 199 | if ("count" in metric) { |
| 200 | values[name] = bucket.rows.length; |
| 201 | continue; |
| 202 | } |
| 203 | if ("countDistinct" in metric) { |
| 204 | const field = String(metric.countDistinct); |
| 205 | values[name] = new Set( |
| 206 | bucket.rows |
| 207 | .map((row) => row[field]) |
| 208 | .filter((value) => value !== undefined && value !== null) |
| 209 | .map((value) => JSON.stringify(value)), |
| 210 | ).size; |
| 211 | continue; |
| 212 | } |
| 213 | const [kind, field] = Object.entries(metric)[0] as [string, string]; |
| 214 | const numbers = bucket.rows |
| 215 | .map((row) => row[field]) |
| 216 | .filter((value): value is number => typeof value === "number" && Number.isFinite(value)); |
| 217 | if (numbers.length === 0) { |
| 218 | values[name] = kind === "sum" ? 0 : null; |
| 219 | continue; |
| 220 | } |
| 221 | if (kind === "sum") values[name] = numbers.reduce((total, value) => total + value, 0); |
| 222 | else if (kind === "avg") values[name] = numbers.reduce((total, value) => total + value, 0) / numbers.length; |
| 223 | else if (kind === "min") values[name] = Math.min(...numbers); |
| 224 | else if (kind === "max") values[name] = Math.max(...numbers); |
| 225 | } |
| 226 | return { key: bucket.key, values }; |
| 227 | }); |
| 228 |
| 229 | const orderKey = input.orderBy?.key; |
| 230 | if (orderKey) { |
| 231 | const direction = input.orderBy?.order === "asc" ? 1 : -1; |
| 232 | groups.sort((a, b) => { |
| 233 | const left = orderKey in a.values ? a.values[orderKey] : a.key[orderKey]; |
| 234 | const right = orderKey in b.values ? b.values[orderKey] : b.key[orderKey]; |
| 235 | return compareRecordValues(left, right) * direction; |
| 236 | }); |
| 237 | } |
| 238 | return { groups: groups.slice(0, input.limit ?? 1000) }; |
| 239 | } |
| 240 |