diff --git a/gen-typebox.js b/gen-typebox.js new file mode 100644 index 0000000..9d93860 --- /dev/null +++ b/gen-typebox.js @@ -0,0 +1,135 @@ +import { dereferencedDocSchemas as originals } from '@comapeo/schema' +import { schema2typebox } from 'schema2typebox' +import * as ts from 'typescript' + +import { mkdir, writeFile } from 'node:fs/promises' +import { join } from 'node:path' + +const TO_GEN = ['observation', 'track', 'preset', 'field'] + +// We extend the schema instead of assigning values to a clone +// because JSDoc has no clean way to mark nested trees as mutable + +const observationSchema = extendProperties(originals.observation, { + attachments: { + type: 'array', + items: { + type: 'object', + properties: { + url: { + type: 'string', + description: 'Path to fetching attachment data', + }, + }, + }, + }, + // We add URLs to various `ref` fields inline with how attachments get URLs + presetRef: addUrlField(originals.observation.properties.presetRef), +}) + +const presetSchema = extendProperties(originals.preset, { + fieldRefs: addUrlFieldArray(originals.preset.properties.fieldRefs), + iconRef: addUrlField(originals.preset.properties.iconRef), +}) + +const trackSchema = extendProperties(originals.track, { + observationRefs: addUrlFieldArray(originals.track.properties.observationRefs), + presetRef: addUrlField(originals.track.properties.presetRef), +}) + +const schemas = { + ...originals, + observation: observationSchema, + preset: presetSchema, + track: trackSchema, +} + +const dataTypesDir = join(import.meta.dirname, './src/datatypes') + +await mkdir(dataTypesDir, { + recursive: true, +}) + +// schema2typebox delcars var witu `var` instead of const +// This interferes with our lint rules so we convert it to const +const matchVar = / var /gu + +await Promise.all( + TO_GEN.map(async (name) => { + const schema = schemas[name] + + console.log(name, 'parsing') + const file = JSON.stringify(schema) + + console.log(name, 'generating ts') + const source = await schema2typebox({ input: file }) + + console.log(name, 'compiling') + // They output TS so we want to translate it directly to JS + const { outputText: compiled } = ts.transpileModule(source, { + compilerOptions: { + module: ts.ModuleKind.ES2022, + }, + }) + + const final = compiled.replace(matchVar, ' const ') + + const outPath = join(dataTypesDir, `${name}.js`) + console.log(name, 'saving', outPath) + await writeFile(outPath, final) + console.log(name, 'done!') + }), +) + +/** + * Extends the properties of a schema with new properties. + * + * @param {Record} schema - The original schema. + * @param {Record} properties - New properties to extend the schema with. + * @returns {Record} - The extended schema with additional properties. + */ +function extendProperties(schema, properties) { + return { + ...schema, + properties: { + ...schema.properties, + ...properties, + }, + } +} + +/** + * @typedef {{ properties: Record, required?: Readonly }} SchemaWithProperties + */ + +/** + * Adds a URL field to a JSON schema object + * @template {object} T + * @param {T & SchemaWithProperties} schema - The JSON schema object to extend + * @returns {T & { properties: { url: { type: 'string' } } }} The schema with an added url property + */ +function addUrlField(schema) { + const required = schema.required ? schema.required.concat('url') : ['url'] + + return { + ...schema, + properties: { + ...schema.properties, + url: { type: 'string' }, + }, + required, + } +} + +/** + * Adds a URL field to the properties of each item in the array within a JSON schema object. + * @template {object} T + * @param {T & { items: SchemaWithProperties }} arraySchema - The JSON schema object with an array as its items to extend + * @returns {T & { items: { properties: { url: { type: 'string' } } } }} The schema with a URL field added to each item's properties + */ +function addUrlFieldArray(arraySchema) { + return { + ...arraySchema, + items: addUrlField(arraySchema.items), + } +} diff --git a/package-lock.json b/package-lock.json index 0788f80..6b9473d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "@fastify/websocket": "^10.0.1", "@mapeo/crypto": "^1.0.0-alpha.10", "@sinclair/typebox": "^0.33.17", + "ajv": "^8.17.1", "env-schema": "^6.0.0", "fastify": "^4.28.1", "string-timing-safe-equal": "^0.1.0", @@ -37,10 +38,24 @@ "npm-run-all": "^4.1.5", "prettier": "^3.3.3", "random-access-memory": "^6.2.1", + "schema2typebox": "^1.7.8", "streamx": "^2.22.1", "typescript": "^5.6.3" } }, + "node_modules/@apidevtools/json-schema-ref-parser": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-9.0.9.tgz", + "integrity": "sha512-GBD2Le9w2+lVFoc4vswGI/TjkNIZSVp7+9xPf+X3uidBfWnAeUWmquteSyt0+VCrhNMWj/FTABISQrD3Z/YA+w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jsdevtools/ono": "^7.1.3", + "@types/json-schema": "^7.0.6", + "call-me-maybe": "^1.0.1", + "js-yaml": "^4.1.0" + } + }, "node_modules/@babel/code-frame": { "version": "7.24.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.24.7.tgz", @@ -717,6 +732,23 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/@eslint/eslintrc/node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, "node_modules/@eslint/eslintrc/node_modules/globals": { "version": "14.0.0", "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", @@ -730,6 +762,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@eslint/eslintrc/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, "node_modules/@eslint/js": { "version": "9.14.0", "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.14.0.tgz", @@ -800,28 +839,6 @@ "fast-uri": "^2.0.0" } }, - "node_modules/@fastify/ajv-compiler/node_modules/ajv": { - "version": "8.16.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.16.0.tgz", - "integrity": "sha512-F0twR8U1ZU67JIEtekUcLkXkoO5mMMmgGD8sK/xUFzJ805jxHQl92hImFAqqXMyMYjSPOyUPAwHYhB72g5sTXw==", - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2", - "uri-js": "^4.4.1" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/@fastify/ajv-compiler/node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "license": "MIT" - }, "node_modules/@fastify/error": { "version": "3.4.1", "resolved": "https://registry.npmjs.org/@fastify/error/-/error-3.4.1.tgz", @@ -1420,6 +1437,13 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@jsdevtools/ono": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/@jsdevtools/ono/-/ono-7.1.3.tgz", + "integrity": "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==", + "dev": true, + "license": "MIT" + }, "node_modules/@jsep-plugin/assignment": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/@jsep-plugin/assignment/-/assignment-1.3.0.tgz", @@ -2152,16 +2176,15 @@ } }, "node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", - "dev": true, + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", + "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "license": "MIT", "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" }, "funding": { "type": "github", @@ -2185,27 +2208,21 @@ } } }, - "node_modules/ajv-formats/node_modules/ajv": { - "version": "8.16.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.16.0.tgz", - "integrity": "sha512-F0twR8U1ZU67JIEtekUcLkXkoO5mMMmgGD8sK/xUFzJ805jxHQl92hImFAqqXMyMYjSPOyUPAwHYhB72g5sTXw==", - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2", - "uri-js": "^4.4.1" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/ajv-formats/node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "license": "MIT" + "node_modules/ajv/node_modules/fast-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" }, "node_modules/ansi-diff": { "version": "1.2.0", @@ -2821,6 +2838,19 @@ "node": ">=6" } }, + "node_modules/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -3108,6 +3138,54 @@ "xache": "^1.1.0" } }, + "node_modules/cosmiconfig": { + "version": "8.1.3", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-8.1.3.tgz", + "integrity": "sha512-/UkO2JKI18b5jVMJUp0lvKFMpa/Gye+ZgZjKD+DGEN9y7NRcf/nK1A0sp67ONmKtnDCNMS44E6jrk0Yc3bDuUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "parse-json": "^5.0.0", + "path-type": "^4.0.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/d-fischer" + } + }, + "node_modules/cosmiconfig/node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cosmiconfig/node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/crc": { "version": "3.8.0", "resolved": "https://registry.npmjs.org/crc/-/crc-3.8.0.tgz", @@ -3644,34 +3722,6 @@ "dotenv-expand": "10.0.0" } }, - "node_modules/env-schema/node_modules/ajv": { - "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", - "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/env-schema/node_modules/fast-uri": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.3.tgz", - "integrity": "sha512-aLrHthzCjH5He4Z2H9YZ+v6Ujb9ocRuW6ZzkJQOrTxleEijANq4v1TsaPaVG1PZcuurEzrLcWRyYBYXD5cEiaw==", - "license": "BSD-3-Clause" - }, - "node_modules/env-schema/node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "license": "MIT" - }, "node_modules/environment": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", @@ -3943,6 +3993,30 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/eslint/node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/eslint/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, "node_modules/espree": { "version": "10.3.0", "resolved": "https://registry.npmjs.org/espree/-/espree-10.3.0.tgz", @@ -4137,22 +4211,6 @@ "rfdc": "^1.2.0" } }, - "node_modules/fast-json-stringify/node_modules/ajv": { - "version": "8.16.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.16.0.tgz", - "integrity": "sha512-F0twR8U1ZU67JIEtekUcLkXkoO5mMMmgGD8sK/xUFzJ805jxHQl92hImFAqqXMyMYjSPOyUPAwHYhB72g5sTXw==", - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2", - "uri-js": "^4.4.1" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, "node_modules/fast-json-stringify/node_modules/ajv-formats": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", @@ -4170,12 +4228,6 @@ } } }, - "node_modules/fast-json-stringify/node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "license": "MIT" - }, "node_modules/fast-levenshtein": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", @@ -4410,6 +4462,13 @@ "node": ">= 0.6" } }, + "node_modules/fp-ts": { + "version": "2.16.0", + "resolved": "https://registry.npmjs.org/fp-ts/-/fp-ts-2.16.0.tgz", + "integrity": "sha512-bLq+KgbiXdTEoT1zcARrWEpa5z6A/8b7PcDW7Gef3NSisQ+VS7ll2Xbf1E+xsgik0rWub/8u0qP/iTTjj+PhxQ==", + "dev": true, + "license": "MIT" + }, "node_modules/fs-constants": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", @@ -5560,6 +5619,13 @@ "dev": true, "license": "MIT" }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "dev": true, + "license": "MIT" + }, "node_modules/json-schema-faker": { "version": "0.5.9", "resolved": "https://registry.npmjs.org/json-schema-faker/-/json-schema-faker-0.5.9.tgz", @@ -5621,10 +5687,9 @@ } }, "node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true, + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "license": "MIT" }, "node_modules/json-stable-stringify": { @@ -5794,6 +5859,13 @@ "url": "https://github.com/sponsors/antonk52" } }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, "node_modules/lint-staged": { "version": "15.2.10", "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-15.2.10.tgz", @@ -7252,6 +7324,27 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, + "node_modules/prettier-plugin-organize-imports": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/prettier-plugin-organize-imports/-/prettier-plugin-organize-imports-3.2.2.tgz", + "integrity": "sha512-e97lE6odGSiHonHJMTYC0q0iLXQyw0u5z/PJpvP/3vRy6/Zi9kLBwFAbEGjDzIowpjQv8b+J04PDamoUSQbzGA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@volar/vue-language-plugin-pug": "^1.0.4", + "@volar/vue-typescript": "^1.0.4", + "prettier": ">=2.0", + "typescript": ">=2.9" + }, + "peerDependenciesMeta": { + "@volar/vue-language-plugin-pug": { + "optional": true + }, + "@volar/vue-typescript": { + "optional": true + } + } + }, "node_modules/pretty-bytes": { "version": "6.1.1", "resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-6.1.1.tgz", @@ -7380,6 +7473,7 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -7820,6 +7914,57 @@ "integrity": "sha512-Eqn7N2yV+aKMlUHTRqUwYG1Iv0cJqjlvLKj/GoP5PozJn361QaOYX14+v87r7NqQUZC22noP/LfLrSQiPwAygw==", "license": "MIT" }, + "node_modules/schema2typebox": { + "version": "1.7.8", + "resolved": "https://registry.npmjs.org/schema2typebox/-/schema2typebox-1.7.8.tgz", + "integrity": "sha512-R64nZFQERGBV1jEDahJ9B0c4VN+HCWc/EkLCmJ3N44jKwCO1wZZ4sa5VsL1aoc0y4y1HznQDyj0WbP+gt4K3LQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@apidevtools/json-schema-ref-parser": "9.0.9", + "camelcase": "6.3.0", + "cosmiconfig": "8.1.3", + "fp-ts": "2.16.0", + "minimist": "1.2.8", + "prettier": "2.8.8", + "prettier-plugin-organize-imports": "3.2.2", + "typescript": "5.0.4" + }, + "bin": { + "schema-to-typebox": "dist/src/bin.js", + "schema2typebox": "dist/src/bin.js" + } + }, + "node_modules/schema2typebox/node_modules/prettier": { + "version": "2.8.8", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.8.8.tgz", + "integrity": "sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin-prettier.js" + }, + "engines": { + "node": ">=10.13.0" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/schema2typebox/node_modules/typescript": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.0.4.tgz", + "integrity": "sha512-cW9T5W9xY37cc+jfEnaUvX91foxtHkza3Nw3wkoF4sSlKn0MONdkdEndig/qPBWXNkmplh3NzayQzCiHM4/hqw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=12.20" + } + }, "node_modules/secure-json-parse": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-2.7.0.tgz", @@ -8953,6 +9098,7 @@ "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, "license": "BSD-2-Clause", "dependencies": { "punycode": "^2.1.0" diff --git a/package.json b/package.json index 58a8848..fa1ac15 100644 --- a/package.json +++ b/package.json @@ -18,8 +18,10 @@ "start": "node src/server.js", "build:clean": "rm -rf dist", "build:typescript": "tsc --project ./tsconfig.build.json", - "build": "npm-run-all --serial build:clean build:typescript", + "build:schemas": "node gen-typebox.js", + "build": "npm-run-all --serial build:clean build:typescript build:schemas", "format": "prettier --write .", + "lint": "npm-run-all --serial test:eslint test:typescript", "test:prettier": "prettier --check .", "test:eslint": "eslint .", "test:typescript": "tsc --project ./tsconfig.json", @@ -57,6 +59,7 @@ "npm-run-all": "^4.1.5", "prettier": "^3.3.3", "random-access-memory": "^6.2.1", + "schema2typebox": "^1.7.8", "streamx": "^2.22.1", "typescript": "^5.6.3" }, @@ -67,6 +70,7 @@ "@fastify/websocket": "^10.0.1", "@mapeo/crypto": "^1.0.0-alpha.10", "@sinclair/typebox": "^0.33.17", + "ajv": "^8.17.1", "env-schema": "^6.0.0", "fastify": "^4.28.1", "string-timing-safe-equal": "^0.1.0", diff --git a/src/datatypes/field.js b/src/datatypes/field.js new file mode 100644 index 0000000..ded19c2 --- /dev/null +++ b/src/datatypes/field.js @@ -0,0 +1,148 @@ +/** + * ATTENTION. This code was AUTO GENERATED by schema2typebox. + * While I don't know your use case, there is a high chance that direct changes + * to this file get lost. Consider making changes to the underlying JSON schema + * you use to generate this file instead. The default file is called + * "schema.json", perhaps have a look there! :] + */ +import { Type } from '@sinclair/typebox' + +export const Field = Type.Object( + { + docId: Type.String({ + description: 'Hex-encoded 32-byte buffer', + minLength: 1, + }), + versionId: Type.String({ + description: + "core discovery id (hex-encoded 32-byte buffer) and core index number, separated by '/'", + minLength: 1, + }), + originalVersionId: Type.String({ + description: + 'Version ID of the original version of this document. For the original version, matches `versionId`.', + minLength: 1, + }), + schemaName: Type.Literal('field', { description: 'Must be `field`' }), + createdAt: Type.String({ + description: + 'RFC3339-formatted datetime of when the first version of the element was created', + format: 'date-time', + }), + updatedAt: Type.String({ + description: + 'RFC3339-formatted datetime of when this version of the element was created', + format: 'date-time', + }), + links: Type.Array(Type.String(), { + description: + "Version ids of the previous document versions this one is replacing. Each link is id (hex-encoded 32 byte buffer) and index number, separated by '/'", + uniqueItems: true, + }), + deleted: Type.Boolean({ + description: 'Indicates whether the document has been deleted', + }), + tagKey: Type.String({ + description: 'They key in a tags object that this field applies to', + minLength: 1, + }), + type: Type.Union( + [ + Type.Literal('type_unspecified'), + Type.Literal('text'), + Type.Literal('number'), + Type.Literal('selectOne'), + Type.Literal('selectMultiple'), + Type.Literal('UNRECOGNIZED'), + ], + { + description: + 'Type of field - defines how the field is displayed to the user.', + 'meta:enum': { + type_unspecified: + 'for backwards compatibility, unspecified type of appearance', + text: 'Freeform text field', + number: 'Allows only numbers', + selectOne: 'Select one item from a list of pre-defined options', + selectMultiple: + 'Select any number of items from a list of pre-defined options', + }, + }, + ), + label: Type.String({ + description: 'Default language label for the form field label', + minLength: 1, + }), + appearance: Type.Optional( + Type.Union( + [ + Type.Literal('appearance_unspecified'), + Type.Literal('singleline'), + Type.Literal('multiline'), + Type.Literal('UNRECOGNIZED'), + ], + { + description: + 'For text fields, display as a single-line or multi-line field', + 'meta:enum': { + appearance_unspecified: + 'for backwards compatibility, unspecified type of appearance', + singleline: 'Text will be cut-off if more than one line', + multiline: 'Text will wrap to multiple lines within text field', + }, + default: 'multiline', + }, + ), + ), + snakeCase: Type.Optional( + Type.Boolean({ + description: + 'Convert field value into snake_case (replace spaces with underscores and convert to lowercase)', + default: false, + }), + ), + options: Type.Optional( + Type.Array( + Type.Object({ + label: Type.String({ minLength: 1 }), + value: Type.Union([ + Type.String(), + Type.Boolean(), + Type.Number(), + Type.Null(), + ]), + }), + { + description: + 'List of options the user can select for single- or multi-select fields', + }, + ), + ), + universal: Type.Optional( + Type.Boolean({ + description: + 'If true, this field will appear in the Add Field list for all presets', + default: false, + }), + ), + placeholder: Type.Optional( + Type.String({ + description: + "Displayed as a placeholder in an empty text or number field before the user begins typing. Use 'helperText' for important information, because the placeholder is not visible after the user has entered data.", + }), + ), + helperText: Type.Optional( + Type.String({ + description: + 'Additional context about the field, e.g. hints about how to answer the question.', + }), + ), + }, + { + $schema: 'http://json-schema.org/draft-07/schema#', + $id: 'http://mapeo.world/schemas/field/v1.json', + description: + 'A field defines a form field that will be shown to the user when creating or editing a map entity. Presets define which fields are shown to the user for a particular map entity. The field definition defines whether the field should show as a text box, multiple choice, single-select, etc. It defines what tag-value is set when the field is entered.', + additionalProperties: false, + }, +) diff --git a/src/datatypes/observation.js b/src/datatypes/observation.js new file mode 100644 index 0000000..56c562b --- /dev/null +++ b/src/datatypes/observation.js @@ -0,0 +1,229 @@ +/** + * ATTENTION. This code was AUTO GENERATED by schema2typebox. + * While I don't know your use case, there is a high chance that direct changes + * to this file get lost. Consider making changes to the underlying JSON schema + * you use to generate this file instead. The default file is called + * "schema.json", perhaps have a look there! :] + */ +import { Type } from '@sinclair/typebox' + +export const Observation = Type.Object( + { + docId: Type.String({ + description: 'Hex-encoded 32-byte buffer', + minLength: 1, + }), + versionId: Type.String({ + description: + "core discovery id (hex-encoded 32-byte buffer) and core index number, separated by '/'", + minLength: 1, + }), + originalVersionId: Type.String({ + description: + 'Version ID of the original version of this document. For the original version, matches `versionId`.', + minLength: 1, + }), + schemaName: Type.Literal('observation', { + description: 'Must be `observation`', + }), + createdAt: Type.String({ + description: + 'RFC3339-formatted datetime of when the first version of the element was created', + format: 'date-time', + }), + updatedAt: Type.String({ + description: + 'RFC3339-formatted datetime of when this version of the element was created', + format: 'date-time', + }), + links: Type.Array(Type.String(), { + description: + "Version ids of the previous document versions this one is replacing. Each link is id (hex-encoded 32 byte buffer) and index number, separated by '/'", + uniqueItems: true, + }), + deleted: Type.Boolean({ + description: 'Indicates whether the document has been deleted', + }), + lat: Type.Optional( + Type.Number({ + description: 'latitude of the observation', + minimum: -90, + maximum: 90, + }), + ), + lon: Type.Optional( + Type.Number({ + description: 'longitude of the observation', + minimum: -180, + maximum: 180, + }), + ), + attachments: Type.Array( + Type.Object({ + url: Type.Optional( + Type.String({ description: 'Path to fetching attachment data' }), + ), + }), + ), + tags: Type.Record( + Type.String(), + Type.Union([ + Type.Boolean(), + Type.Number(), + Type.String(), + Type.Null(), + Type.Array( + Type.Union([ + Type.Boolean(), + Type.Number(), + Type.String(), + Type.Null(), + ]), + ), + ]), + ), + metadata: Type.Optional( + Type.Object( + { + manualLocation: Type.Optional( + Type.Boolean({ + description: 'Whether location has been set manually', + default: false, + }), + ), + position: Type.Optional( + Type.Object( + { + timestamp: Type.String({ + description: + 'Timestamp of when the current position was obtained', + format: 'date-time', + }), + mocked: Type.Optional( + Type.Boolean({ + description: '`true` if the position was mocked', + default: false, + }), + ), + coords: Type.Object( + { + latitude: Type.Number(), + longitude: Type.Number(), + altitude: Type.Optional(Type.Number()), + altitudeAccuracy: Type.Optional(Type.Number()), + heading: Type.Optional(Type.Number()), + speed: Type.Optional(Type.Number()), + accuracy: Type.Optional(Type.Number()), + }, + { + description: + 'Position details, should be self explanatory. Units in meters', + }, + ), + }, + { description: 'Position details' }, + ), + ), + lastSavedPosition: Type.Optional( + Type.Object( + { + timestamp: Type.String({ + description: + 'Timestamp of when the current position was obtained', + format: 'date-time', + }), + mocked: Type.Optional( + Type.Boolean({ + description: '`true` if the position was mocked', + default: false, + }), + ), + coords: Type.Object( + { + latitude: Type.Number(), + longitude: Type.Number(), + altitude: Type.Optional(Type.Number()), + altitudeAccuracy: Type.Optional(Type.Number()), + heading: Type.Optional(Type.Number()), + speed: Type.Optional(Type.Number()), + accuracy: Type.Optional(Type.Number()), + }, + { + description: + 'Position details, should be self explanatory. Units in meters', + }, + ), + }, + { description: 'Position details' }, + ), + ), + positionProvider: Type.Optional( + Type.Object( + { + gpsAvailable: Type.Optional( + Type.Boolean({ + description: + 'Whether the user has enabled GPS for device location (this is not the same as whether location is turned on or off, this is a device setting whether to use just wifi and bluetooth or use GPS for location)', + }), + ), + passiveAvailable: Type.Optional( + Type.Boolean({ + description: + 'Whether the device is configured to lookup location based on wifi and bluetooth networks', + }), + ), + locationServicesEnabled: Type.Boolean({ + description: + 'Has the user enabled location services on the device (this is often turned off when the device is in airplane mode)', + }), + networkAvailable: Type.Optional( + Type.Boolean({ + description: + 'Whether the device can lookup location based on cell phone towers', + }), + ), + }, + { + description: + 'Details of the location providers that were available on the device when the observation was recorded', + }, + ), + ), + }, + { + description: + 'Additional metadata associated with the observation (e.g. location precision, altitude, heading)', + additionalProperties: false, + }, + ), + ), + presetRef: Type.Optional( + Type.Object( + { + docId: Type.String({ + description: + 'hex-encoded id of the element that this observation references', + minLength: 1, + }), + versionId: Type.String({ + description: + "core discovery id (hex-encoded 32-byte buffer) and core index number, separated by '/'", + minLength: 1, + }), + url: Type.String(), + }, + { + description: + 'References to the preset that this observation is related to.', + }, + ), + ), + }, + { + $schema: 'http://json-schema.org/draft-07/schema#', + $id: 'http://mapeo.world/schemas/observation/v1.json', + description: + "An observation is something that has been observed at a particular time and place. It is a subjective statement of 'I saw/heard this, here'", + additionalProperties: false, + }, +) diff --git a/src/datatypes/preset.js b/src/datatypes/preset.js new file mode 100644 index 0000000..ae5e706 --- /dev/null +++ b/src/datatypes/preset.js @@ -0,0 +1,168 @@ +/** + * ATTENTION. This code was AUTO GENERATED by schema2typebox. + * While I don't know your use case, there is a high chance that direct changes + * to this file get lost. Consider making changes to the underlying JSON schema + * you use to generate this file instead. The default file is called + * "schema.json", perhaps have a look there! :] + */ +import { Type } from '@sinclair/typebox' + +export const Preset = Type.Object( + { + docId: Type.String({ + description: 'Hex-encoded 32-byte buffer', + minLength: 1, + }), + versionId: Type.String({ + description: + "core discovery id (hex-encoded 32-byte buffer) and core index number, separated by '/'", + minLength: 1, + }), + originalVersionId: Type.String({ + description: + 'Version ID of the original version of this document. For the original version, matches `versionId`.', + minLength: 1, + }), + schemaName: Type.Literal('preset', { description: 'Must be `preset`' }), + createdAt: Type.String({ + description: + 'RFC3339-formatted datetime of when the first version of the element was created', + format: 'date-time', + }), + updatedAt: Type.String({ + description: + 'RFC3339-formatted datetime of when this version of the element was created', + format: 'date-time', + }), + links: Type.Array(Type.String(), { + description: + "Version ids of the previous document versions this one is replacing. Each link is id (hex-encoded 32 byte buffer) and index number, separated by '/'", + uniqueItems: true, + }), + deleted: Type.Boolean({ + description: 'Indicates whether the document has been deleted', + }), + name: Type.String({ + description: 'Name for the feature in default language.', + }), + geometry: Type.Array( + Type.Union([ + Type.Literal('point'), + Type.Literal('vertex'), + Type.Literal('line'), + Type.Literal('area'), + Type.Literal('relation'), + ]), + { + description: + 'Valid geometry types for the feature - this preset will only match features of this geometry type `"point", "vertex", "line", "area", "relation"`', + uniqueItems: true, + }, + ), + tags: Type.Record( + Type.String(), + Type.Union([ + Type.Boolean(), + Type.Number(), + Type.String(), + Type.Null(), + Type.Array( + Type.Union([ + Type.Boolean(), + Type.Number(), + Type.String(), + Type.Null(), + ]), + ), + ]), + ), + addTags: Type.Record( + Type.String(), + Type.Union([ + Type.Boolean(), + Type.Number(), + Type.String(), + Type.Null(), + Type.Array( + Type.Union([ + Type.Boolean(), + Type.Number(), + Type.String(), + Type.Null(), + ]), + ), + ]), + ), + removeTags: Type.Record( + Type.String(), + Type.Union([ + Type.Boolean(), + Type.Number(), + Type.String(), + Type.Null(), + Type.Array( + Type.Union([ + Type.Boolean(), + Type.Number(), + Type.String(), + Type.Null(), + ]), + ), + ]), + ), + fieldRefs: Type.Array( + Type.Object({ + docId: Type.String({ + description: + 'hex-encoded id of the element that this observation references', + minLength: 1, + }), + versionId: Type.String({ + description: + "core discovery id (hex-encoded 32-byte buffer) and core index number, separated by '/'", + minLength: 1, + }), + url: Type.String(), + }), + { + description: 'References to any fields that this preset is related to.', + }, + ), + iconRef: Type.Optional( + Type.Object( + { + docId: Type.String({ + description: + 'hex-encoded id of the element that this observation references', + minLength: 1, + }), + versionId: Type.String({ + description: + "core discovery id (hex-encoded 32-byte buffer) and core index number, separated by '/'", + minLength: 1, + }), + url: Type.String(), + }, + { + description: 'References to the icon that this preset is related to.', + }, + ), + ), + terms: Type.Array(Type.String(), { + description: 'Synonyms or related terms (used for search)', + }), + color: Type.Optional( + Type.String({ + description: 'string representation of a color in 24 bit (#rrggbb)', + pattern: '^#[a-fA-F0-9]{6}$', + }), + ), + }, + { + $schema: 'http://json-schema.org/draft-07/schema#', + $id: 'http://mapeo.world/schemas/preset/v1.json', + description: + 'Presets define how map entities are displayed to the user. They define the icon used on the map, and the fields / questions shown to the user when they create or edit the entity on the map. The `tags` property of a preset is used to match the preset with observations, nodes, ways and relations. If multiple presets match, the one that matches the most tags is used.', + additionalProperties: false, + }, +) diff --git a/src/datatypes/track.js b/src/datatypes/track.js new file mode 100644 index 0000000..c848a45 --- /dev/null +++ b/src/datatypes/track.js @@ -0,0 +1,141 @@ +/** + * ATTENTION. This code was AUTO GENERATED by schema2typebox. + * While I don't know your use case, there is a high chance that direct changes + * to this file get lost. Consider making changes to the underlying JSON schema + * you use to generate this file instead. The default file is called + * "schema.json", perhaps have a look there! :] + */ +import { Type } from '@sinclair/typebox' + +export const Track = Type.Object( + { + docId: Type.String({ + description: 'Hex-encoded 32-byte buffer', + minLength: 1, + }), + versionId: Type.String({ + description: + "core discovery id (hex-encoded 32-byte buffer) and core index number, separated by '/'", + minLength: 1, + }), + originalVersionId: Type.String({ + description: + 'Version ID of the original version of this document. For the original version, matches `versionId`.', + minLength: 1, + }), + schemaName: Type.Literal('track', { description: 'Must be `track`' }), + createdAt: Type.String({ + description: + 'RFC3339-formatted datetime of when the first version of the element was created', + format: 'date-time', + }), + updatedAt: Type.String({ + description: + 'RFC3339-formatted datetime of when this version of the element was created', + format: 'date-time', + }), + links: Type.Array(Type.String(), { + description: + "Version ids of the previous document versions this one is replacing. Each link is id (hex-encoded 32 byte buffer) and index number, separated by '/'", + uniqueItems: true, + }), + deleted: Type.Boolean({ + description: 'Indicates whether the document has been deleted', + }), + locations: Type.Array( + Type.Object( + { + timestamp: Type.String({ + description: + 'Timestamp (ISO date string) of when the current position was obtained', + format: 'date-time', + }), + mocked: Type.Boolean({ + description: '`true` if the position was mocked', + default: false, + }), + coords: Type.Object( + { + latitude: Type.Number(), + longitude: Type.Number(), + altitude: Type.Optional(Type.Number()), + heading: Type.Optional(Type.Number()), + speed: Type.Optional(Type.Number()), + accuracy: Type.Optional(Type.Number()), + }, + { + description: + 'Position details, should be self explanatory. Units in meters', + }, + ), + }, + { description: 'Position details' }, + ), + { description: 'Array of positions along the track' }, + ), + observationRefs: Type.Array( + Type.Object({ + docId: Type.String({ + description: + 'hex-encoded id of the element that this track references', + minLength: 1, + }), + versionId: Type.String({ + description: + "core discovery id (hex-encoded 32-byte buffer) and core index number, separated by '/'", + minLength: 1, + }), + url: Type.String(), + }), + { + description: + 'References to any observations that this track is related to.', + }, + ), + tags: Type.Record( + Type.String(), + Type.Union([ + Type.Boolean(), + Type.Number(), + Type.String(), + Type.Null(), + Type.Array( + Type.Union([ + Type.Boolean(), + Type.Number(), + Type.String(), + Type.Null(), + ]), + ), + ]), + ), + presetRef: Type.Optional( + Type.Object( + { + docId: Type.String({ + description: + 'hex-encoded id of the element that this track references', + minLength: 1, + }), + versionId: Type.String({ + description: + "core discovery id (hex-encoded 32-byte buffer) and core index number, separated by '/'", + minLength: 1, + }), + url: Type.String(), + }, + { + description: + 'References to the preset that this track is related to.', + }, + ), + ), + }, + { + $schema: 'http://json-schema.org/draft-07/schema#', + $id: 'http://mapeo.world/schemas/track/v1.json', + description: + 'A track is a recording of positions over time, with associated tags, similar to an observation', + additionalProperties: false, + }, +) diff --git a/src/errors.js b/src/errors.js index ca81b3a..7b24327 100644 --- a/src/errors.js +++ b/src/errors.js @@ -43,6 +43,9 @@ export const tooManyProjects = () => export const projectNotFoundError = () => new HttpError(404, 'PROJECT_NOT_FOUND', 'Project not found') +export const iconNotFoundErrror = () => + new HttpError(404, 'ICON_NOT_FOUND', 'Icon not found') + /** @param {never} value */ export const shouldBeImpossibleError = (value) => new Error(`${value} should be impossible`) diff --git a/src/routes.js b/src/routes.js index a0b5ba9..2f1d534 100644 --- a/src/routes.js +++ b/src/routes.js @@ -7,12 +7,27 @@ import assert from 'node:assert/strict' import * as fs from 'node:fs' import { STATUS_CODES } from 'node:http' +import { Field as fieldSchema } from './datatypes/field.js' +import { Observation as observationSchema } from './datatypes/observation.js' +import { Preset as presetSchema } from './datatypes/preset.js' +import { Track as trackSchema } from './datatypes/track.js' import * as errors from './errors.js' import * as schemas from './schemas.js' +import { HEX_STRING_32_BYTES } from './schemas.js' import { wsCoreReplicator } from './ws-core-replicator.js' /** @import { FastifyInstance, FastifyPluginAsync, FastifyRequest, RawServerDefault } from 'fastify' */ /** @import { TypeBoxTypeProvider } from '@fastify/type-provider-typebox' */ +/** @import { MapeoDoc } from '@comapeo/schema' */ +/** @import { MapeoProject } from '@comapeo/core/dist/mapeo-project.js' */ +/** @import {Static, TSchema} from '@sinclair/typebox' */ +/** + * @template {MapeoDoc['schemaName']} TSchemaName + * @typedef {Extract} GetMapeoDoc + */ +/** @typedef {{baseUrl: URL, projectPublicId: string, project: MapeoProject}} MapDocParam */ +/** @typedef {{docId: string, versionId: string}} Ref*/ +/** @typedef {{docId: string, versionId: string, url: string}} UrlRef */ const BEARER_SPACE_LENGTH = 'Bearer '.length @@ -267,59 +282,29 @@ export default async function routes( }, ) - fastify.get( - '/projects/:projectPublicId/observations', - { - schema: { - params: Type.Object({ - projectPublicId: BASE32_STRING_32_BYTES, - }), - response: { - 200: Type.Object({ - data: Type.Array(schemas.observationResult), - }), - '4xx': schemas.errorResponse, - }, - }, - async preHandler(req) { - verifyBearerAuth(req) - await ensureProjectExists(this, req) - }, - }, - /** - * @this {FastifyInstance} - */ - async function (req) { - const { projectPublicId } = req.params - const project = await this.comapeo.getProject(projectPublicId) - - return { - data: (await project.observation.getMany({ includeDeleted: true })).map( - (obs) => ({ - docId: obs.docId, - createdAt: obs.createdAt, - updatedAt: obs.updatedAt, - deleted: obs.deleted, - lat: obs.lat, - lon: obs.lon, - attachments: obs.attachments - .filter((attachment) => - SUPPORTED_ATTACHMENT_TYPES.has( - /** @type {any} */ (attachment.type), - ), - ) - .map((attachment) => ({ - url: new URL( - `projects/${projectPublicId}/attachments/${attachment.driveDiscoveryId}/${attachment.type}/${attachment.name}`, - req.baseUrl, - ).href, - })), - tags: obs.tags, - }), - ), - } - }, + addDatatypeGetter('observation', observationSchema, setAttachmentURL) + // TODO: backwards compat, remove this in next major release + addDatatypeGetter( + 'observation', + observationSchema, + setAttachmentURL, + 'observations', ) + addDatatypeGetter('track', trackSchema, (track, params) => ({ + ...track, + presetRef: expandRef(track.presetRef, 'preset', params), + observationRefs: expandManyRefs( + track.observationRefs, + 'observation', + params, + ), + })) + addDatatypeGetter('preset', presetSchema, (preset, params) => ({ + ...preset, + fieldRefs: expandManyRefs(preset.fieldRefs, 'field', params), + iconRef: expandRef(preset.iconRef, 'icon', params), + })) + addDatatypeGetter('field', fieldSchema, (field) => field) fastify.get( '/projects/:projectPublicId/remoteDetectionAlerts', @@ -399,6 +384,69 @@ export default async function routes( }, ) + fastify.get( + `/projects/:projectPublicId/icon/:docId`, + { + schema: { + params: Type.Object({ + projectPublicId: BASE32_STRING_32_BYTES, + docId: schemas.HEX_STRING_32_BYTES, + }), + querystring: Type.Object({ + size: Type.Optional( + Type.Union([ + Type.Literal('small'), + Type.Literal('medium'), + Type.Literal('large'), + ]), + ), + }), + response: { + 200: {}, + '4xx': schemas.errorResponse, + }, + }, + async preHandler(req) { + verifyBearerAuth(req) + await ensureProjectExists(this, req) + }, + }, + /** + * @this {FastifyInstance} + */ + async function (req, reply) { + const { projectPublicId, docId } = req.params + const { size = 'medium' } = req.query + const project = await this.comapeo.getProject(projectPublicId) + + const iconUrl = await project.$icons.getIconUrl(docId, { + mimeType: 'image/svg+xml', + size, + }) + + let proxiedResponse = await fetch(iconUrl) + + // Fall back to png + if (proxiedResponse.status === 404) { + const iconUrl = await project.$icons.getIconUrl(docId, { + mimeType: 'image/png', + pixelDensity: 1, + size, + }) + proxiedResponse = await fetch(iconUrl) + } + // We should keep our errors consistant + if (proxiedResponse.status !== 200) { + throw errors.iconNotFoundErrror() + } + reply.code(proxiedResponse.status) + for (const [headerName, headerValue] of proxiedResponse.headers) { + reply.header(headerName, headerValue) + } + return reply.send(proxiedResponse.body) + }, + ) + fastify.get( '/projects/:projectPublicId/attachments/:driveDiscoveryId/:type/:name', { @@ -478,6 +526,173 @@ export default async function routes( return reply.send(proxiedResponse.body) }, ) + + /** + * @template {TSchema} Schema + * @template {"track"|"observation"|"preset"|"field"} TDataType + * @param {TDataType} dataType - DataType to pull from + * @param {Schema} responseSchema - Schema for the response data + * @param {(doc: GetMapeoDoc, req: MapDocParam) => Static|Promise>} mapDoc - Add / remove fields + * @param {string} [typeRoute] - Route to mount the getters under. Defaults to the dataType + */ + function addDatatypeGetter( + dataType, + responseSchema, + mapDoc, + typeRoute = dataType, + ) { + fastify.get( + `/projects/:projectPublicId/${typeRoute}`, + { + schema: { + params: Type.Object({ + projectPublicId: BASE32_STRING_32_BYTES, + }), + response: { + 200: { + type: 'object', + properties: { + data: { + type: 'array', + items: responseSchema, + }, + }, + }, + '4xx': schemas.errorResponse, + }, + }, + async preHandler(req) { + verifyBearerAuth(req) + await ensureProjectExists(this, req) + }, + }, + /** + * @this {FastifyInstance} + */ + async function (req) { + const { projectPublicId } = req.params + const project = await this.comapeo.getProject(projectPublicId) + + const datatype = project[dataType] + + const data = await Promise.all( + (await datatype.getMany({ includeDeleted: true })).map((doc) => + mapDoc(/** @type {GetMapeoDoc}*/ (doc), { + projectPublicId, + project, + baseUrl: req.baseUrl, + }), + ), + ) + + return { data } + }, + ) + + fastify.get( + `/projects/:projectPublicId/${typeRoute}/:docId`, + { + schema: { + params: Type.Object({ + projectPublicId: BASE32_STRING_32_BYTES, + docId: HEX_STRING_32_BYTES, + }), + response: { + 200: { + type: 'object', + properties: { + data: responseSchema, + }, + }, + '4xx': schemas.errorResponse, + }, + }, + async preHandler(req) { + verifyBearerAuth(req) + await ensureProjectExists(this, req) + }, + }, + /** + * @this {FastifyInstance} + */ + async function (req) { + const { projectPublicId, docId } = req.params + const project = await this.comapeo.getProject(projectPublicId) + + const datatype = project[dataType] + + const rawData = await datatype.getByDocId(docId) + + const data = await mapDoc( + /** @type {GetMapeoDoc}*/ (rawData), + { + projectPublicId, + project, + baseUrl: req.baseUrl, + }, + ) + + return { data } + }, + ) + } +} + +/** + * @param {Ref | undefined} ref + * @param {string} dataType + * @param {MapDocParam} opts + * @returns {UrlRef | undefined} + */ +function expandRef(ref, dataType, { projectPublicId, baseUrl }) { + if (!ref) return ref + return { + ...ref, + url: new URL( + `projects/${projectPublicId}/${dataType}/${ref.docId}`, + baseUrl, + ).href, + } +} + +/** + * @param {Ref[] | undefined} refs + * @param {string} dataType + * @param {MapDocParam} opts + * @returns {UrlRef[]} + */ +function expandManyRefs(refs, dataType, { projectPublicId, baseUrl }) { + if (!refs) return [] + return refs.map((ref) => ({ + ...ref, + url: new URL( + `projects/${projectPublicId}/${dataType}/${ref.docId}`, + baseUrl, + ).href, + })) +} + +/** + * + * @param {GetMapeoDoc<"observation">} obs + * @param {MapDocParam} params + * @returns {Static} + */ +function setAttachmentURL(obs, params) { + return { + ...obs, + attachments: obs.attachments + .filter((attachment) => + SUPPORTED_ATTACHMENT_TYPES.has(/** @type {any} */ (attachment.type)), + ) + .map((attachment) => ({ + url: new URL( + `projects/${params.projectPublicId}/attachments/${attachment.driveDiscoveryId}/${attachment.type}/${attachment.name}`, + params.baseUrl, + ).href, + })), + presetRef: expandRef(obs.presetRef, 'preset', params), + } } /** diff --git a/src/schemas.js b/src/schemas.js index 44b6c29..d7d4d75 100644 --- a/src/schemas.js +++ b/src/schemas.js @@ -26,32 +26,6 @@ export const projectToAdd = Type.Object({ }), }) -export const observationResult = Type.Object({ - docId: Type.String(), - createdAt: dateTimeString, - updatedAt: dateTimeString, - deleted: Type.Boolean(), - lat: Type.Optional(latitude), - lon: Type.Optional(longitude), - attachments: Type.Array( - Type.Object({ - url: Type.String(), - }), - ), - tags: Type.Record( - Type.String(), - Type.Union([ - Type.Boolean(), - Type.Number(), - Type.String(), - Type.Null(), - Type.Array( - Type.Union([Type.Boolean(), Type.Number(), Type.String(), Type.Null()]), - ), - ]), - ), -}) - const position = Type.Tuple([longitude, latitude]) const remoteDetectionAlertCommon = { diff --git a/src/server.js b/src/server.js index 73a2f73..cae373d 100644 --- a/src/server.js +++ b/src/server.js @@ -79,6 +79,7 @@ if (!rootKey || rootKey.length !== 16) { const fastify = createFastify({ logger: true, trustProxy: true, + maxParamLength: 256, }) fastify.register(comapeoServer, { serverName: config.SERVER_NAME, diff --git a/test/fixtures/validConfig.zip b/test/fixtures/validConfig.zip new file mode 100644 index 0000000..8cd135e Binary files /dev/null and b/test/fixtures/validConfig.zip differ diff --git a/test/icon-endpoint.js b/test/icon-endpoint.js new file mode 100644 index 0000000..d8de4b3 --- /dev/null +++ b/test/icon-endpoint.js @@ -0,0 +1,63 @@ +import { keyToPublicId as projectKeyToPublicId } from '@mapeo/crypto' + +import assert from 'node:assert/strict' +import test from 'node:test' + +import { + BEARER_TOKEN, + createTestServer, + randomAddProjectBody, + randomProjectPublicId, +} from './test-helpers.js' + +const FAKE_DOC_ID = new Array(64).fill('a').join('') + +// Note: We test fetching the icon in the preset tests + +test('returns a 401 if no auth is provided', async (t) => { + const server = createTestServer(t) + + const response = await server.inject({ + method: 'GET', + url: `/projects/${randomProjectPublicId()}/icon/${FAKE_DOC_ID}`, + }) + assert.equal(response.statusCode, 401) + assert.equal(response.json().error.code, 'UNAUTHORIZED') +}) + +test('returns a 401 if incorrect auth is provided', async (t) => { + const server = createTestServer(t) + + const response = await server.inject({ + method: 'GET', + url: `/projects/${randomProjectPublicId()}/icon/${FAKE_DOC_ID}`, + headers: { Authorization: 'Bearer bad' }, + }) + assert.equal(response.statusCode, 401) + assert.equal(response.json().error.code, 'UNAUTHORIZED') +}) + +test('returns 404 if no icon is found', async (t) => { + const server = createTestServer(t) + const projectKeys = randomAddProjectBody() + const projectPublicId = projectKeyToPublicId( + Buffer.from(projectKeys.projectKey, 'hex'), + ) + + await server.listen() + + const addProjectResponse = await server.inject({ + method: 'PUT', + url: '/projects', + body: projectKeys, + }) + assert.equal(addProjectResponse.statusCode, 200) + + const response = await server.inject({ + method: 'GET', + url: `/projects/${projectPublicId}/icon/${FAKE_DOC_ID}`, + headers: { Authorization: 'Bearer ' + BEARER_TOKEN }, + }) + assert.equal(response.statusCode, 404) + assert.equal(response.json().error.code, 'ICON_NOT_FOUND') +}) diff --git a/test/observations-endpoint.js b/test/observations-endpoint.js index c5855c5..a0b5ed1 100644 --- a/test/observations-endpoint.js +++ b/test/observations-endpoint.js @@ -1,7 +1,5 @@ import { MapeoManager } from '@comapeo/core' -import { valueOf } from '@comapeo/schema' import { keyToPublicId as projectKeyToPublicId } from '@mapeo/crypto' -import { generate } from '@mapeo/mock-data' import { map } from 'iterpal' import assert from 'node:assert/strict' @@ -11,6 +9,7 @@ import test from 'node:test' import { BEARER_TOKEN, createTestServer, + generateObservation, getManagerOptions, randomAddProjectBody, randomProjectPublicId, @@ -171,12 +170,6 @@ test('returning observations with fetchable attachments', async (t) => { ) }) -function generateObservation() { - const observationDoc = generate('observation')[0] - assert(observationDoc) - return valueOf(observationDoc) -} - /** * @param {object} blob * @param {string} blob.driveId diff --git a/test/preset-endpoint.js b/test/preset-endpoint.js new file mode 100644 index 0000000..7d3124b --- /dev/null +++ b/test/preset-endpoint.js @@ -0,0 +1,300 @@ +import { MapeoManager } from '@comapeo/core' +import { keyToPublicId as projectKeyToPublicId } from '@mapeo/crypto' + +import assert from 'node:assert/strict' +import test from 'node:test' + +import { + BEARER_TOKEN, + createTestServer, + generateObservation, + generatePreset, + generateTrack, + getManagerOptions, + randomAddProjectBody, + randomProjectPublicId, + runWithRetries, +} from './test-helpers.js' + +/** @import {Static} from '@sinclair/typebox' */ +/** @import {Preset} from '../src/datatypes/preset.js' */ +/** @import {Observation} from '../src/datatypes/observation.js' */ +/** @import {Track} from '../src/datatypes/track.js' */ + +test('returns a 401 if no auth is provided', async (t) => { + const server = createTestServer(t) + + const response = await server.inject({ + method: 'GET', + url: `/projects/${randomProjectPublicId()}/preset`, + }) + assert.equal(response.statusCode, 401) + assert.equal(response.json().error.code, 'UNAUTHORIZED') +}) + +test('returns a 401 if incorrect auth is provided', async (t) => { + const server = createTestServer(t) + + const response = await server.inject({ + method: 'GET', + url: `/projects/${randomProjectPublicId()}/preset`, + headers: { Authorization: 'Bearer bad' }, + }) + assert.equal(response.statusCode, 401) + assert.equal(response.json().error.code, 'UNAUTHORIZED') +}) + +test('returning no presets', async (t) => { + const server = createTestServer(t) + const projectKeys = randomAddProjectBody() + const projectPublicId = projectKeyToPublicId( + Buffer.from(projectKeys.projectKey, 'hex'), + ) + + const addProjectResponse = await server.inject({ + method: 'PUT', + url: '/projects', + body: projectKeys, + }) + assert.equal(addProjectResponse.statusCode, 200) + + const response = await server.inject({ + method: 'GET', + url: `/projects/${projectPublicId}/preset`, + headers: { Authorization: 'Bearer ' + BEARER_TOKEN }, + }) + assert.equal(response.statusCode, 200) + assert.deepEqual(await response.json(), { data: [] }) +}) + +test('fetch presetRef in observation', async (t) => { + const server = createTestServer(t) + + const serverAddress = await server.listen() + const serverUrl = new URL(serverAddress) + + const manager = new MapeoManager(getManagerOptions()) + const projectId = await manager.createProject({ name: 'CoMapeo project' }) + const project = await manager.getProject(projectId) + + await project.$member.addServerPeer(serverAddress, { + dangerouslyAllowInsecureConnections: true, + }) + + project.$sync.start() + project.$sync.connectServers() + + const presets = await generatePreset(project) + + const observationPreset = presets.filter( + ({ geometry }) => geometry.length === 1 && geometry[0] === 'point', + )[0] + + assert(observationPreset) + + const observation = await project.observation.create({ + ...generateObservation(), + presetRef: { + docId: observationPreset.docId, + versionId: observationPreset.versionId, + }, + }) + + await project.$sync.waitForSync('full') + + const gotObservation = /** @type {Static}*/ ( + await runWithRetries(3, async () => { + const response = await server.inject({ + authority: serverUrl.host, + method: 'GET', + url: `/projects/${projectId}/observation/${observation.docId}`, + headers: { Authorization: 'Bearer ' + BEARER_TOKEN }, + }) + assert.equal(response.statusCode, 200) + + const { data } = await response.json() + return data + }) + ) + + assert.equal( + gotObservation.presetRef?.docId, + observationPreset.docId, + 'observation references preset', + ) + + const presetResponse = await server.inject({ + method: 'GET', + url: gotObservation.presetRef.url, + headers: { Authorization: 'Bearer ' + BEARER_TOKEN }, + }) + assert.equal( + presetResponse.statusCode, + 200, + 'able to fetch preset from ref URL', + ) + + // The ref fields won't match so lets ignore them + const { + data: { iconRef: _, ...fetchedPreset }, + } = await presetResponse.json() + + const { forks: _2, iconRef: _3, ...expectedPreset } = observationPreset + + assert.deepEqual(fetchedPreset, expectedPreset, 'fetched preset matches') +}) + +test('fetch presetRef in track', async (t) => { + const server = createTestServer(t) + + const serverAddress = await server.listen() + const serverUrl = new URL(serverAddress) + + const manager = new MapeoManager(getManagerOptions()) + const projectId = await manager.createProject({ name: 'CoMapeo project' }) + const project = await manager.getProject(projectId) + + await project.$member.addServerPeer(serverAddress, { + dangerouslyAllowInsecureConnections: true, + }) + + project.$sync.start() + project.$sync.connectServers() + + const presets = await generatePreset(project) + + const trackPreset = presets.filter( + ({ geometry }) => geometry.length === 1 && geometry[0] === 'line', + )[0] + + assert(trackPreset) + + const track = await project.track.create({ + ...generateTrack(), + presetRef: { + docId: trackPreset.docId, + versionId: trackPreset.versionId, + }, + }) + + await project.$sync.waitForSync('full') + + const gotTrack = /** @type {Static}*/ ( + await runWithRetries(3, async () => { + const response = await server.inject({ + authority: serverUrl.host, + method: 'GET', + url: `/projects/${projectId}/track/${track.docId}`, + headers: { Authorization: 'Bearer ' + BEARER_TOKEN }, + }) + assert.equal(response.statusCode, 200) + + const { data } = await response.json() + return data + }) + ) + + assert.equal( + gotTrack.presetRef?.docId, + trackPreset.docId, + 'track references preset', + ) + + const presetResponse = await server.inject({ + method: 'GET', + url: gotTrack.presetRef.url, + headers: { Authorization: 'Bearer ' + BEARER_TOKEN }, + }) + assert.equal( + presetResponse.statusCode, + 200, + 'able to fetch preset from ref URL', + ) + + // The ref fields won't match so lets ignore them + const { + data: { iconRef: _, ...fetchedPreset }, + } = await presetResponse.json() + + const { forks: _2, iconRef: _3, ...expectedPreset } = trackPreset + + assert.deepEqual(fetchedPreset, expectedPreset, 'fetched preset matches') +}) + +test('returning presets with fetchable fields and icons', async (t) => { + const server = createTestServer(t) + + const serverAddress = await server.listen() + const serverUrl = new URL(serverAddress) + + const manager = new MapeoManager(getManagerOptions()) + const projectId = await manager.createProject({ name: 'CoMapeo project' }) + const project = await manager.getProject(projectId) + + await project.$member.addServerPeer(serverAddress, { + dangerouslyAllowInsecureConnections: true, + }) + + project.$sync.start() + project.$sync.connectServers() + + const presets = await generatePreset(project) + + await project.$sync.waitForSync('full') + + // It's possible that the client thinks it's synced but the server hasn't + // processed everything yet, so we try a few times. + const gotPresets = /** @type {Static[]}*/ ( + await runWithRetries(3, async () => { + const response = await server.inject({ + authority: serverUrl.host, + method: 'GET', + url: `/projects/${projectId}/preset`, + headers: { Authorization: 'Bearer ' + BEARER_TOKEN }, + }) + assert.equal(response.statusCode, 200) + + const { data } = await response.json() + assert.equal(data.length, presets.length) + return data + }) + ) + + for (const preset of presets) { + const found = gotPresets.find(({ docId }) => docId === preset.docId) + assert(found, 'preset got returned') + const { fieldRefs, iconRef, ...generalData } = found + const { fieldRefs: _, iconRef: _2, forks: _3, ...expectedData } = preset + assert.deepEqual(generalData, expectedData, 'general preset fields match') + + assert(fieldRefs, 'preset has fieldRefs') + if (!fieldRefs.length) continue + assert(fieldRefs[0]?.url, 'refs have url') + + const fieldResponse = await server.inject({ + method: 'GET', + url: new URL(fieldRefs[0].url).pathname, + headers: { Authorization: 'Bearer ' + BEARER_TOKEN }, + }) + assert.equal( + fieldResponse.statusCode, + 200, + 'able to fetch field from ref URL', + ) + + const { data: fetchedField } = await fieldResponse.json() + assert.equal(fetchedField.schemaName, 'field') + + if (!iconRef) continue + const iconResponse = await server.inject({ + method: 'GET', + url: new URL(iconRef.url).pathname, + headers: { Authorization: 'Bearer ' + BEARER_TOKEN }, + }) + assert.equal( + iconResponse.statusCode, + 200, + 'able to fetch icon from ref URL', + ) + } +}) diff --git a/test/test-helpers.js b/test/test-helpers.js index 8a71512..2f69160 100644 --- a/test/test-helpers.js +++ b/test/test-helpers.js @@ -10,6 +10,7 @@ import RAM from 'random-access-memory' import assert from 'node:assert/strict' import { randomBytes } from 'node:crypto' import { setTimeout as delay } from 'node:timers/promises' +import { fileURLToPath } from 'node:url' import comapeoServer from '../src/app.js' @@ -17,6 +18,7 @@ import comapeoServer from '../src/app.js' /** @import { TestContext } from 'node:test' */ /** @import { FastifyInstance } from 'fastify' */ /** @import { ServerOptions } from '../src/app.js' */ +/** @import { MapeoProject } from '@comapeo/core/dist/mapeo-project.js' */ export const BEARER_TOKEN = Buffer.from('swordfish').toString('base64') @@ -55,7 +57,9 @@ export function getManagerOptions() { export function createTestServer(t, serverOptions) { const managerOptions = getManagerOptions() const km = new KeyManager(managerOptions.rootKey) - const server = createFastify() + const server = createFastify({ + maxParamLength: 256, + }) server.register(comapeoServer, { ...managerOptions, ...TEST_SERVER_DEFAULTS, @@ -163,3 +167,42 @@ export function generateAlerts( ) return alerts.map((alert) => valueOf(alert)) } + +/** + * Import preset from config and return one of them that could be used for tests + * @param {MapeoProject} project + * @returns {Promise>} + */ +export async function generatePreset(project) { + await project.importConfig({ + configPath: fileURLToPath( + import.meta.resolve('./fixtures/validConfig.zip'), + ), + }) + + const presets = await project.preset.getMany() + + assert(presets.length, 'presets got created') + + return presets +} + +/** + * Generate a new observation + * @returns {import('@comapeo/schema').ObservationValue} + */ +export function generateObservation() { + const observationDoc = generate('observation', { count: 1 })[0] + assert(observationDoc) + return valueOf(observationDoc) +} + +/** + * Generate a new track + * @returns {import('@comapeo/schema').TrackValue} + */ +export function generateTrack() { + const trackDoc = generate('track', { count: 1 })[0] + assert(trackDoc) + return valueOf(trackDoc) +} diff --git a/test/tracks-endpoint.js b/test/tracks-endpoint.js new file mode 100644 index 0000000..493aed9 --- /dev/null +++ b/test/tracks-endpoint.js @@ -0,0 +1,170 @@ +import { MapeoManager } from '@comapeo/core' +import { valueOf } from '@comapeo/schema' +import { keyToPublicId as projectKeyToPublicId } from '@mapeo/crypto' +import { generate } from '@mapeo/mock-data' + +import assert from 'node:assert/strict' +import test from 'node:test' + +import { + BEARER_TOKEN, + createTestServer, + getManagerOptions, + randomAddProjectBody, + randomProjectPublicId, + runWithRetries, +} from './test-helpers.js' + +/** @import {Static} from '@sinclair/typebox' */ +/** @import {Track} from '../src/datatypes/track.js' */ + +test('returns a 401 if no auth is provided', async (t) => { + const server = createTestServer(t) + + const response = await server.inject({ + method: 'GET', + url: `/projects/${randomProjectPublicId()}/track`, + }) + assert.equal(response.statusCode, 401) + assert.equal(response.json().error.code, 'UNAUTHORIZED') +}) + +test('returns a 401 if incorrect auth is provided', async (t) => { + const server = createTestServer(t) + + const response = await server.inject({ + method: 'GET', + url: `/projects/${randomProjectPublicId()}/track`, + headers: { Authorization: 'Bearer bad' }, + }) + assert.equal(response.statusCode, 401) + assert.equal(response.json().error.code, 'UNAUTHORIZED') +}) + +test('returning no tracks', async (t) => { + const server = createTestServer(t) + const projectKeys = randomAddProjectBody() + const projectPublicId = projectKeyToPublicId( + Buffer.from(projectKeys.projectKey, 'hex'), + ) + + const addProjectResponse = await server.inject({ + method: 'PUT', + url: '/projects', + body: projectKeys, + }) + assert.equal(addProjectResponse.statusCode, 200) + + const response = await server.inject({ + method: 'GET', + url: `/projects/${projectPublicId}/track`, + headers: { Authorization: 'Bearer ' + BEARER_TOKEN }, + }) + assert.equal(response.statusCode, 200) + assert.deepEqual(await response.json(), { data: [] }) +}) + +test('returning tracks with fetchable observations', async (t) => { + const server = createTestServer(t) + + const serverAddress = await server.listen() + const serverUrl = new URL(serverAddress) + + const manager = new MapeoManager(getManagerOptions()) + const projectId = await manager.createProject({ name: 'CoMapeo project' }) + const project = await manager.getProject(projectId) + + await project.$member.addServerPeer(serverAddress, { + dangerouslyAllowInsecureConnections: true, + }) + + project.$sync.start() + project.$sync.connectServers() + + const observations = await Promise.all( + generate('observation', { count: 4 }) + .filter((observation) => observation) + .map((observation) => ({ ...valueOf(observation), attachments: [] })) + .map((observation) => project.observation.create(observation)), + ) + + const [o1, o2, o3, o4] = observations.map(({ docId, versionId }) => ({ + docId, + versionId, + })) + + assert(o1) + assert(o2) + assert(o3) + assert(o4) + + const tracks = await Promise.all([ + project.track.create(makeTrack([o1, o2])), + project.track.create(makeTrack([o3, o4])), + ]) + + await project.$sync.waitForSync('full') + + // It's possible that the client thinks it's synced but the server hasn't + // processed everything yet, so we try a few times. + const gotTracks = /** @type {Static[]}*/ ( + await runWithRetries(3, async () => { + const response = await server.inject({ + authority: serverUrl.host, + method: 'GET', + url: `/projects/${projectId}/track`, + headers: { Authorization: 'Bearer ' + BEARER_TOKEN }, + }) + assert.equal(response.statusCode, 200) + + const { data } = await response.json() + assert.equal(data.length, 2) + return data + }) + ) + + for (const track of tracks) { + const found = gotTracks.find(({ docId }) => docId === track.docId) + assert(found, 'track got returned') + const { observationRefs, ...generalData } = found + const { + observationRefs: _, + // Remove irrelevant fields + presetRef: _2, + forks: _3, + ...expectedData + } = track + assert.deepEqual(generalData, expectedData, 'general track fields match') + assert(observationRefs, 'track has observationRefs') + assert(observationRefs[0]?.url, 'refs have url') + + const observationResponse = await server.inject({ + method: 'GET', + url: new URL(observationRefs[0].url).pathname, + headers: { Authorization: 'Bearer ' + BEARER_TOKEN }, + }) + assert.equal( + observationResponse.statusCode, + 200, + 'able to fetch observation from ref URL', + ) + + const { data: fetchedObservation } = await observationResponse.json() + assert.equal(fetchedObservation.schemaName, 'observation') + } +}) + +/** + * + * @param {{docId: string, versionId: string}[]} observationRefs + * @returns {import('@comapeo/schema').TrackValue} + */ +function makeTrack(observationRefs) { + const rawTrack = generate('track', { count: 1 })[0] + delete rawTrack?.presetRef + assert(rawTrack) + return { + ...valueOf(rawTrack), + observationRefs, + } +}