@construct-space/cli 1.6.0 → 1.6.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js
CHANGED
|
@@ -8026,6 +8026,11 @@ function readRaw(dir) {
|
|
|
8026
8026
|
const data = readFileSync2(path, "utf-8");
|
|
8027
8027
|
return JSON.parse(data);
|
|
8028
8028
|
}
|
|
8029
|
+
function write(dir, m) {
|
|
8030
|
+
const path = join3(dir, MANIFEST_FILE);
|
|
8031
|
+
writeFileSync2(path, JSON.stringify(m, null, 2) + `
|
|
8032
|
+
`);
|
|
8033
|
+
}
|
|
8029
8034
|
function writeWithBuild(dir, raw, build) {
|
|
8030
8035
|
raw.build = build;
|
|
8031
8036
|
writeFileSync2(join3(dir, "manifest.json"), JSON.stringify(raw, null, 2) + `
|
|
@@ -8109,7 +8114,7 @@ function resolvePages(m, root, prefix) {
|
|
|
8109
8114
|
if (existsSync4(legacyFullPath)) {
|
|
8110
8115
|
let varName = "IndexPage";
|
|
8111
8116
|
if (p.path) {
|
|
8112
|
-
varName = capitalize(p.path.replace(/[\/:]/g, "-").replace(/-+/g, "-")) + "Page";
|
|
8117
|
+
varName = capitalize(p.path.replace(/\[([^\]]+)\]/g, "$1").replace(/[\/:]/g, "-").replace(/-+/g, "-")) + "Page";
|
|
8113
8118
|
}
|
|
8114
8119
|
return { varName, importPath: prefix + legacyComponent, path: p.path };
|
|
8115
8120
|
}
|
|
@@ -8243,35 +8248,174 @@ function bundleAgentDir(srcDir, distDir) {
|
|
|
8243
8248
|
}
|
|
8244
8249
|
|
|
8245
8250
|
// src/commands/build.ts
|
|
8251
|
+
function stripComments(source) {
|
|
8252
|
+
let out = "";
|
|
8253
|
+
let i = 0;
|
|
8254
|
+
let quote = null;
|
|
8255
|
+
let escaped = false;
|
|
8256
|
+
while (i < source.length) {
|
|
8257
|
+
const ch = source[i];
|
|
8258
|
+
const next = source[i + 1];
|
|
8259
|
+
if (quote) {
|
|
8260
|
+
out += ch;
|
|
8261
|
+
if (escaped) {
|
|
8262
|
+
escaped = false;
|
|
8263
|
+
} else if (ch === "\\") {
|
|
8264
|
+
escaped = true;
|
|
8265
|
+
} else if (ch === quote) {
|
|
8266
|
+
quote = null;
|
|
8267
|
+
}
|
|
8268
|
+
i++;
|
|
8269
|
+
continue;
|
|
8270
|
+
}
|
|
8271
|
+
if (ch === '"' || ch === "'" || ch === "`") {
|
|
8272
|
+
quote = ch;
|
|
8273
|
+
out += ch;
|
|
8274
|
+
i++;
|
|
8275
|
+
continue;
|
|
8276
|
+
}
|
|
8277
|
+
if (ch === "/" && next === "/") {
|
|
8278
|
+
while (i < source.length && source[i] !== `
|
|
8279
|
+
`) {
|
|
8280
|
+
out += " ";
|
|
8281
|
+
i++;
|
|
8282
|
+
}
|
|
8283
|
+
continue;
|
|
8284
|
+
}
|
|
8285
|
+
if (ch === "/" && next === "*") {
|
|
8286
|
+
out += " ";
|
|
8287
|
+
i += 2;
|
|
8288
|
+
while (i < source.length && !(source[i] === "*" && source[i + 1] === "/")) {
|
|
8289
|
+
out += source[i] === `
|
|
8290
|
+
` ? `
|
|
8291
|
+
` : " ";
|
|
8292
|
+
i++;
|
|
8293
|
+
}
|
|
8294
|
+
if (i < source.length) {
|
|
8295
|
+
out += " ";
|
|
8296
|
+
i += 2;
|
|
8297
|
+
}
|
|
8298
|
+
continue;
|
|
8299
|
+
}
|
|
8300
|
+
out += ch;
|
|
8301
|
+
i++;
|
|
8302
|
+
}
|
|
8303
|
+
return out;
|
|
8304
|
+
}
|
|
8305
|
+
function findMatchingBrace(source, openIndex) {
|
|
8306
|
+
let depth = 0;
|
|
8307
|
+
let quote = null;
|
|
8308
|
+
let escaped = false;
|
|
8309
|
+
for (let i = openIndex;i < source.length; i++) {
|
|
8310
|
+
const ch = source[i];
|
|
8311
|
+
if (quote) {
|
|
8312
|
+
if (escaped)
|
|
8313
|
+
escaped = false;
|
|
8314
|
+
else if (ch === "\\")
|
|
8315
|
+
escaped = true;
|
|
8316
|
+
else if (ch === quote)
|
|
8317
|
+
quote = null;
|
|
8318
|
+
continue;
|
|
8319
|
+
}
|
|
8320
|
+
if (ch === '"' || ch === "'" || ch === "`") {
|
|
8321
|
+
quote = ch;
|
|
8322
|
+
continue;
|
|
8323
|
+
}
|
|
8324
|
+
if (ch === "{")
|
|
8325
|
+
depth++;
|
|
8326
|
+
else if (ch === "}") {
|
|
8327
|
+
depth--;
|
|
8328
|
+
if (depth === 0)
|
|
8329
|
+
return i;
|
|
8330
|
+
}
|
|
8331
|
+
}
|
|
8332
|
+
return -1;
|
|
8333
|
+
}
|
|
8334
|
+
function objectLiteralAfter(source, start) {
|
|
8335
|
+
const open = source.indexOf("{", start);
|
|
8336
|
+
if (open < 0)
|
|
8337
|
+
return null;
|
|
8338
|
+
const close = findMatchingBrace(source, open);
|
|
8339
|
+
if (close < 0)
|
|
8340
|
+
return null;
|
|
8341
|
+
return source.slice(open + 1, close);
|
|
8342
|
+
}
|
|
8343
|
+
function actionsObject(source) {
|
|
8344
|
+
const stripped = stripComments(source);
|
|
8345
|
+
const match = /export\s+const\s+actions\s*=/.exec(stripped);
|
|
8346
|
+
if (!match)
|
|
8347
|
+
return null;
|
|
8348
|
+
return objectLiteralAfter(stripped, match.index + match[0].length);
|
|
8349
|
+
}
|
|
8350
|
+
function objectEntries(body) {
|
|
8351
|
+
const entries = [];
|
|
8352
|
+
let i = 0;
|
|
8353
|
+
while (i < body.length) {
|
|
8354
|
+
const match = /\s*,?\s*([A-Za-z_$][\w$]*)\s*:/.exec(body.slice(i));
|
|
8355
|
+
if (!match)
|
|
8356
|
+
break;
|
|
8357
|
+
const name = match[1];
|
|
8358
|
+
const valueStart = i + match.index + match[0].length;
|
|
8359
|
+
const open = body.indexOf("{", valueStart);
|
|
8360
|
+
if (open < 0)
|
|
8361
|
+
break;
|
|
8362
|
+
if (body.slice(valueStart, open).trim()) {
|
|
8363
|
+
i = valueStart;
|
|
8364
|
+
continue;
|
|
8365
|
+
}
|
|
8366
|
+
const close = findMatchingBrace(body, open);
|
|
8367
|
+
if (close < 0)
|
|
8368
|
+
break;
|
|
8369
|
+
entries.push({ name, body: body.slice(open + 1, close) });
|
|
8370
|
+
i = close + 1;
|
|
8371
|
+
}
|
|
8372
|
+
return entries;
|
|
8373
|
+
}
|
|
8374
|
+
function stringProperty(body, name) {
|
|
8375
|
+
const match = new RegExp(`${name}\\s*:\\s*['"\`]([^'"\`]*)['"\`]`).exec(body);
|
|
8376
|
+
return match?.[1];
|
|
8377
|
+
}
|
|
8378
|
+
function booleanProperty(body, name) {
|
|
8379
|
+
const match = new RegExp(`${name}\\s*:\\s*(true|false)`).exec(body);
|
|
8380
|
+
if (!match)
|
|
8381
|
+
return;
|
|
8382
|
+
return match[1] === "true";
|
|
8383
|
+
}
|
|
8384
|
+
function propertyObject(body, name) {
|
|
8385
|
+
const match = new RegExp(`${name}\\s*:`).exec(body);
|
|
8386
|
+
if (!match)
|
|
8387
|
+
return null;
|
|
8388
|
+
return objectLiteralAfter(body, match.index + match[0].length);
|
|
8389
|
+
}
|
|
8246
8390
|
function extractActionMetadata(actionsPath) {
|
|
8247
8391
|
try {
|
|
8248
|
-
const
|
|
8392
|
+
const actionsBody = actionsObject(readFileSync4(actionsPath, "utf-8"));
|
|
8393
|
+
if (!actionsBody)
|
|
8394
|
+
return null;
|
|
8249
8395
|
const result = {};
|
|
8250
|
-
const
|
|
8251
|
-
|
|
8252
|
-
|
|
8253
|
-
|
|
8254
|
-
const
|
|
8255
|
-
|
|
8256
|
-
|
|
8257
|
-
for (const actionId of Object.keys(result)) {
|
|
8258
|
-
const paramBlockPattern = new RegExp(`${actionId}\\s*:\\s*\\{[\\s\\S]*?params\\s*:\\s*\\{([\\s\\S]*?)\\}\\s*,?\\s*(?:run|\\})`);
|
|
8259
|
-
const paramMatch = source.match(paramBlockPattern);
|
|
8260
|
-
if (paramMatch?.[1]) {
|
|
8396
|
+
for (const action of objectEntries(actionsBody)) {
|
|
8397
|
+
const description = stringProperty(action.body, "description");
|
|
8398
|
+
if (!description)
|
|
8399
|
+
continue;
|
|
8400
|
+
const metadata = { description };
|
|
8401
|
+
const paramsBody = propertyObject(action.body, "params");
|
|
8402
|
+
if (paramsBody) {
|
|
8261
8403
|
const params = {};
|
|
8262
|
-
const
|
|
8263
|
-
|
|
8264
|
-
|
|
8265
|
-
|
|
8266
|
-
|
|
8267
|
-
|
|
8268
|
-
|
|
8404
|
+
for (const param of objectEntries(paramsBody)) {
|
|
8405
|
+
const type = stringProperty(param.body, "type");
|
|
8406
|
+
if (!type)
|
|
8407
|
+
continue;
|
|
8408
|
+
const required = booleanProperty(param.body, "required");
|
|
8409
|
+
params[param.name] = {
|
|
8410
|
+
type,
|
|
8411
|
+
...stringProperty(param.body, "description") ? { description: stringProperty(param.body, "description") } : {},
|
|
8412
|
+
...required === true ? { required: true } : {}
|
|
8269
8413
|
};
|
|
8270
8414
|
}
|
|
8271
|
-
if (Object.keys(params).length > 0)
|
|
8272
|
-
|
|
8273
|
-
}
|
|
8415
|
+
if (Object.keys(params).length > 0)
|
|
8416
|
+
metadata.params = params;
|
|
8274
8417
|
}
|
|
8418
|
+
result[action.name] = metadata;
|
|
8275
8419
|
}
|
|
8276
8420
|
return Object.keys(result).length > 0 ? result : null;
|
|
8277
8421
|
} catch {
|
|
@@ -10319,6 +10463,22 @@ async function publish(options) {
|
|
|
10319
10463
|
init_source();
|
|
10320
10464
|
import { existsSync as existsSync12, readFileSync as readFileSync9 } from "fs";
|
|
10321
10465
|
import { join as join15 } from "path";
|
|
10466
|
+
|
|
10467
|
+
// src/lib/pagePaths.ts
|
|
10468
|
+
function pageComponentFromPath(path) {
|
|
10469
|
+
const clean = path.replace(/^\/+|\/+$/g, "");
|
|
10470
|
+
if (!clean)
|
|
10471
|
+
return "pages/index.vue";
|
|
10472
|
+
const segments = clean.split("/").map((segment) => {
|
|
10473
|
+
if (segment.startsWith(":") && segment.length > 1) {
|
|
10474
|
+
return `[${segment.slice(1)}]`;
|
|
10475
|
+
}
|
|
10476
|
+
return segment;
|
|
10477
|
+
});
|
|
10478
|
+
return `pages/${segments.join("/")}.vue`;
|
|
10479
|
+
}
|
|
10480
|
+
|
|
10481
|
+
// src/commands/validate.ts
|
|
10322
10482
|
function validate3() {
|
|
10323
10483
|
const root = process.cwd();
|
|
10324
10484
|
if (!exists(root)) {
|
|
@@ -10335,7 +10495,7 @@ function validate3() {
|
|
|
10335
10495
|
}
|
|
10336
10496
|
let warnings = 0;
|
|
10337
10497
|
for (const page of m.pages) {
|
|
10338
|
-
const component = page.component || (page.path
|
|
10498
|
+
const component = page.component || pageComponentFromPath(page.path);
|
|
10339
10499
|
const fullPath = join15(root, "src", component);
|
|
10340
10500
|
if (!existsSync12(fullPath)) {
|
|
10341
10501
|
console.log(source_default.yellow(` \u26A0 Page component not found: src/${component}`));
|
|
@@ -10386,7 +10546,7 @@ function check() {
|
|
|
10386
10546
|
}
|
|
10387
10547
|
let warnings = 0;
|
|
10388
10548
|
for (const page of m.pages) {
|
|
10389
|
-
const component = page.component || (page.path
|
|
10549
|
+
const component = page.component || pageComponentFromPath(page.path);
|
|
10390
10550
|
if (!existsSync13(join16(root, "src", component))) {
|
|
10391
10551
|
console.log(source_default.yellow(` \u26A0 Page not found: src/${component}`));
|
|
10392
10552
|
warnings++;
|
|
@@ -10949,6 +11109,33 @@ async function graphPush() {
|
|
|
10949
11109
|
process.exit(1);
|
|
10950
11110
|
}
|
|
10951
11111
|
}
|
|
11112
|
+
function parseDefaultValue(raw) {
|
|
11113
|
+
const value = raw.trim();
|
|
11114
|
+
try {
|
|
11115
|
+
return JSON.parse(value);
|
|
11116
|
+
} catch {}
|
|
11117
|
+
const quote = value[0];
|
|
11118
|
+
if ((quote === "'" || quote === '"') && value.endsWith(quote)) {
|
|
11119
|
+
return value.slice(1, -1).replace(/\\(['"\\bfnrt])/g, (_, ch) => {
|
|
11120
|
+
switch (ch) {
|
|
11121
|
+
case "b":
|
|
11122
|
+
return "\b";
|
|
11123
|
+
case "f":
|
|
11124
|
+
return "\f";
|
|
11125
|
+
case "n":
|
|
11126
|
+
return `
|
|
11127
|
+
`;
|
|
11128
|
+
case "r":
|
|
11129
|
+
return "\r";
|
|
11130
|
+
case "t":
|
|
11131
|
+
return "\t";
|
|
11132
|
+
default:
|
|
11133
|
+
return ch;
|
|
11134
|
+
}
|
|
11135
|
+
});
|
|
11136
|
+
}
|
|
11137
|
+
return value;
|
|
11138
|
+
}
|
|
10952
11139
|
function parseModelFile(content, fileName) {
|
|
10953
11140
|
const modelMatch = content.match(/defineModel\s*\(\s*['"](\w+)['"]/);
|
|
10954
11141
|
if (!modelMatch)
|
|
@@ -10976,11 +11163,7 @@ function parseModelFile(content, fileName) {
|
|
|
10976
11163
|
field.validation = "url";
|
|
10977
11164
|
const defaultMatch = modifiers.match(/\.default\((.+?)\)/);
|
|
10978
11165
|
if (defaultMatch) {
|
|
10979
|
-
|
|
10980
|
-
field.default = JSON.parse(defaultMatch[1]);
|
|
10981
|
-
} catch {
|
|
10982
|
-
field.default = defaultMatch[1];
|
|
10983
|
-
}
|
|
11166
|
+
field.default = parseDefaultValue(defaultMatch[1]);
|
|
10984
11167
|
}
|
|
10985
11168
|
}
|
|
10986
11169
|
fields.push(field);
|
|
@@ -11162,10 +11345,41 @@ function parseModelFields(content, fileName) {
|
|
|
11162
11345
|
}
|
|
11163
11346
|
return { name: modelMatch[1], fields };
|
|
11164
11347
|
}
|
|
11348
|
+
|
|
11349
|
+
// src/commands/graph/fork.ts
|
|
11350
|
+
init_source();
|
|
11351
|
+
var spaceIDRegex = /^[a-z][a-z0-9-]*$/;
|
|
11352
|
+
function forkManifest(root, newSpaceID) {
|
|
11353
|
+
const id = newSpaceID.trim();
|
|
11354
|
+
if (!spaceIDRegex.test(id)) {
|
|
11355
|
+
throw new Error("space id must be lowercase alphanumeric with hyphens, starting with a letter");
|
|
11356
|
+
}
|
|
11357
|
+
if (!exists(root)) {
|
|
11358
|
+
throw new Error("No space.manifest.json found in current directory");
|
|
11359
|
+
}
|
|
11360
|
+
const m = read(root);
|
|
11361
|
+
if (m.id === id) {
|
|
11362
|
+
throw new Error(`space.manifest.json already uses id "${id}"`);
|
|
11363
|
+
}
|
|
11364
|
+
const oldSpaceID = m.id;
|
|
11365
|
+
m.id = id;
|
|
11366
|
+
write(root, m);
|
|
11367
|
+
return { oldSpaceID, newSpaceID: id };
|
|
11368
|
+
}
|
|
11369
|
+
function graphFork(newSpaceID) {
|
|
11370
|
+
try {
|
|
11371
|
+
const result = forkManifest(process.cwd(), newSpaceID);
|
|
11372
|
+
console.log(source_default.green(`Forked graph space id: ${result.oldSpaceID} -> ${result.newSpaceID}`));
|
|
11373
|
+
console.log(source_default.dim("Run `construct graph push` to register the forked schema."));
|
|
11374
|
+
} catch (err) {
|
|
11375
|
+
console.error(source_default.red(err.message));
|
|
11376
|
+
process.exit(1);
|
|
11377
|
+
}
|
|
11378
|
+
}
|
|
11165
11379
|
// package.json
|
|
11166
11380
|
var package_default = {
|
|
11167
11381
|
name: "@construct-space/cli",
|
|
11168
|
-
version: "1.6.
|
|
11382
|
+
version: "1.6.2",
|
|
11169
11383
|
description: "Construct CLI \u2014 scaffold, build, develop, and publish spaces",
|
|
11170
11384
|
type: "module",
|
|
11171
11385
|
bin: {
|
|
@@ -11229,6 +11443,7 @@ var graph = program2.command("graph").description("Construct Graph \u2014 data m
|
|
|
11229
11443
|
graph.command("init").description("Initialize Graph in a space project").action(() => graphInit());
|
|
11230
11444
|
graph.command("generate <model> [fields...]").alias("g").description("Generate a data model").option("--access <rules>", "Access rules (e.g. read:member,create:member,update:owner,delete:admin)").action((model, fields, opts) => generate2(model, fields, opts));
|
|
11231
11445
|
graph.command("push").description("Register models with the Graph service").action(async () => graphPush());
|
|
11446
|
+
graph.command("fork <new-space-id>").description("Rewrite this space manifest to a new graph space id").action((newSpaceID) => graphFork(newSpaceID));
|
|
11232
11447
|
graph.command("migrate").description("Compare local models with server schema and apply changes").option("--apply", "Apply destructive changes (drop columns, alter constraints)").action(async (opts) => graphMigrate(opts));
|
|
11233
11448
|
graph.command("spaces").alias("list").alias("ls").description("List spaces published by your org").option("--org <id>", "Override org id").option("--bundle <id>", "Filter to a single bundle").option("--json", "Output JSON").action(async (opts) => (await Promise.resolve().then(() => (init_spaces(), exports_spaces))).spacesList(opts));
|
|
11234
11449
|
var bundles = graph.command("bundles").description("Manage space bundles (publisher grouping)");
|
|
@@ -100,6 +100,17 @@ All exported from the package root:
|
|
|
100
100
|
|
|
101
101
|
**Composables**: `useNotification`, `notify`, `useAuth`, `useTheme`, `useClipboard`, `useMediaQuery`, `useFormValidation`, `useKeyboard`/`useHotkey`, `useClickOutside`, `useEscapeKey`, `useLocalStorage`, `useAsync`, `useDebounce`/`useThrottle`, `useToggle`, `useSearch`, `useIntersectionObserver`
|
|
102
102
|
|
|
103
|
+
### Component gotchas
|
|
104
|
+
|
|
105
|
+
- **`Avatar` sizes**: only `sm | md | lg` (no `xs`).
|
|
106
|
+
- **`Select`** doesn't accept `multiple` — render a chip-toggle list yourself for multi-select.
|
|
107
|
+
- **`Table`** column `align` literals must be `as const` to typecheck (`align: 'right' as const`).
|
|
108
|
+
- **`Table` rowKey** is a string field name on the row object, not a function.
|
|
109
|
+
- **`Modal`** uses `v-model:open` (not `v-model`); slots: `header | body | footer | accessory`.
|
|
110
|
+
- **`Card`** slots: `header | accessory | default | footer | footer-end`. Use `title`/`description` props for the header to get standard styling; put right-side actions in `accessory`.
|
|
111
|
+
- **`ConfirmationModal`** emits `@confirm` and `@cancel` — wire both, otherwise dismissal does nothing.
|
|
112
|
+
- **Settings pages**: never raw `<select>`/`<input>`/`<textarea>` — use `Select`, `Switch`, `Input`, `Textarea` with `FormField` labels. Persist with `localStorage` (key prefix per space).
|
|
113
|
+
|
|
103
114
|
```vue
|
|
104
115
|
<script setup lang="ts">
|
|
105
116
|
import { Card, Button, Modal, Badge } from '@construct-space/ui'
|
|
@@ -185,9 +196,20 @@ await tasks.remove(id)
|
|
|
185
196
|
|
|
186
197
|
### Pitfalls
|
|
187
198
|
|
|
188
|
-
- **`field.json()` columns** must receive a JSON-stringified value on write: `labels: JSON.stringify(arr)`. On read, parse back if it returns a string. Sending a JS array directly triggers `column is of type jsonb but expression is of type record`.
|
|
199
|
+
- **`field.json()` columns** must receive a JSON-stringified value on write: `labels: JSON.stringify(arr)`. On read, parse back if it returns a string. Sending a JS array directly triggers `column is of type jsonb but expression is of type record`. Pattern:
|
|
200
|
+
```ts
|
|
201
|
+
function parseLabels(raw: unknown): string[] {
|
|
202
|
+
if (Array.isArray(raw)) return raw as string[]
|
|
203
|
+
if (typeof raw === 'string' && raw.trim()) {
|
|
204
|
+
try { const v = JSON.parse(raw); return Array.isArray(v) ? v : [] } catch { return [] }
|
|
205
|
+
}
|
|
206
|
+
return []
|
|
207
|
+
}
|
|
208
|
+
```
|
|
189
209
|
- **Topological sort on push**: avoid `relation.belongsTo()` if push fails — use a `field.string()` foreign-key column instead and join manually in composables.
|
|
190
210
|
- **Lazy provisioning**: first query for a new tenant auto-creates the schema; expect a one-time slower request.
|
|
211
|
+
- **Schema ownership is sticky**: the first profile to `construct graph push` becomes the schema owner. Subsequent pushes from a different profile fail with "Ownership check failed". Bump the space `id` to fork, or push from the original owner profile.
|
|
212
|
+
- **`access.member()` requires org context** — use `access.authenticated()` for org-scoped models that any logged-in member should be able to read/write. `member()` is for stricter membership checks.
|
|
191
213
|
|
|
192
214
|
---
|
|
193
215
|
|
package/package.json
CHANGED
|
@@ -100,6 +100,17 @@ All exported from the package root:
|
|
|
100
100
|
|
|
101
101
|
**Composables**: `useNotification`, `notify`, `useAuth`, `useTheme`, `useClipboard`, `useMediaQuery`, `useFormValidation`, `useKeyboard`/`useHotkey`, `useClickOutside`, `useEscapeKey`, `useLocalStorage`, `useAsync`, `useDebounce`/`useThrottle`, `useToggle`, `useSearch`, `useIntersectionObserver`
|
|
102
102
|
|
|
103
|
+
### Component gotchas
|
|
104
|
+
|
|
105
|
+
- **`Avatar` sizes**: only `sm | md | lg` (no `xs`).
|
|
106
|
+
- **`Select`** doesn't accept `multiple` — render a chip-toggle list yourself for multi-select.
|
|
107
|
+
- **`Table`** column `align` literals must be `as const` to typecheck (`align: 'right' as const`).
|
|
108
|
+
- **`Table` rowKey** is a string field name on the row object, not a function.
|
|
109
|
+
- **`Modal`** uses `v-model:open` (not `v-model`); slots: `header | body | footer | accessory`.
|
|
110
|
+
- **`Card`** slots: `header | accessory | default | footer | footer-end`. Use `title`/`description` props for the header to get standard styling; put right-side actions in `accessory`.
|
|
111
|
+
- **`ConfirmationModal`** emits `@confirm` and `@cancel` — wire both, otherwise dismissal does nothing.
|
|
112
|
+
- **Settings pages**: never raw `<select>`/`<input>`/`<textarea>` — use `Select`, `Switch`, `Input`, `Textarea` with `FormField` labels. Persist with `localStorage` (key prefix per space).
|
|
113
|
+
|
|
103
114
|
```vue
|
|
104
115
|
<script setup lang="ts">
|
|
105
116
|
import { Card, Button, Modal, Badge } from '@construct-space/ui'
|
|
@@ -185,9 +196,20 @@ await tasks.remove(id)
|
|
|
185
196
|
|
|
186
197
|
### Pitfalls
|
|
187
198
|
|
|
188
|
-
- **`field.json()` columns** must receive a JSON-stringified value on write: `labels: JSON.stringify(arr)`. On read, parse back if it returns a string. Sending a JS array directly triggers `column is of type jsonb but expression is of type record`.
|
|
199
|
+
- **`field.json()` columns** must receive a JSON-stringified value on write: `labels: JSON.stringify(arr)`. On read, parse back if it returns a string. Sending a JS array directly triggers `column is of type jsonb but expression is of type record`. Pattern:
|
|
200
|
+
```ts
|
|
201
|
+
function parseLabels(raw: unknown): string[] {
|
|
202
|
+
if (Array.isArray(raw)) return raw as string[]
|
|
203
|
+
if (typeof raw === 'string' && raw.trim()) {
|
|
204
|
+
try { const v = JSON.parse(raw); return Array.isArray(v) ? v : [] } catch { return [] }
|
|
205
|
+
}
|
|
206
|
+
return []
|
|
207
|
+
}
|
|
208
|
+
```
|
|
189
209
|
- **Topological sort on push**: avoid `relation.belongsTo()` if push fails — use a `field.string()` foreign-key column instead and join manually in composables.
|
|
190
210
|
- **Lazy provisioning**: first query for a new tenant auto-creates the schema; expect a one-time slower request.
|
|
211
|
+
- **Schema ownership is sticky**: the first profile to `construct graph push` becomes the schema owner. Subsequent pushes from a different profile fail with "Ownership check failed". Bump the space `id` to fork, or push from the original owner profile.
|
|
212
|
+
- **`access.member()` requires org context** — use `access.authenticated()` for org-scoped models that any logged-in member should be able to read/write. `member()` is for stricter membership checks.
|
|
191
213
|
|
|
192
214
|
---
|
|
193
215
|
|