@bongos/core 1.19.811 → 1.19.813
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/.bongos-core.json +108 -43
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +24 -0
- package/clients/bongos-client/index.cjs +24 -0
- package/clients/bongos-client/index.d.ts +39 -0
- package/clients/bongos-client/index.mjs +24 -0
- package/docs/adr/0296-a-role-speciality-is-data-over-three-existing-surfaces.md +71 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +867 -73
- package/docs/api-reference.md +19 -3
- package/docs/copy-inventory.md +48 -20
- package/docs/copy-registry.json +275 -23
- package/docs/module-api-changelog.md +4 -0
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +24 -0
- package/modules/hall-ui/public/settings-specialities.js +241 -0
- package/modules/hall-ui/public/settings.css +56 -0
- package/modules/hall-ui/public/settings.html +65 -0
- package/modules/lifecycle/db-claim-reads.js +5 -1
- package/modules/lifecycle/db-executor.js +45 -0
- package/modules/lifecycle/db-goals.js +29 -7
- package/modules/lifecycle/db-tasks.js +11 -5
- package/modules/lifecycle/db-versions.js +17 -3
- package/modules/lifecycle/done-when.js +5 -2
- package/modules/lifecycle/goal-edits.js +5 -1
- package/modules/lifecycle/routes/done-when.js +150 -86
- package/modules/memory/learnings.js +21 -2
- package/modules/provisioning/starter-bundles.js +1 -0
- package/modules/specialities/CLAUDE.md +15 -0
- package/modules/specialities/db.js +234 -0
- package/modules/specialities/migrations/specialities_001_specialities.sql +143 -0
- package/modules/specialities/module.json +12 -0
- package/modules/specialities/routes/specialities.js +379 -0
- package/modules/specialities/specialities.js +198 -0
- package/modules/ui-design/kit/serve.js +1 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/publish-manifest.js +1 -0
- package/scripts/gds/search-ingest-db.js +37 -0
- package/src/bongos/routes/me.js +17 -0
- package/src/module-api.js +1 -1
- package/tests/criterion_authoring_gate.mjs +22 -1
- package/tests/criterion_goal_attach.mjs +233 -8
- package/tests/module_loader.mjs +1 -1
- package/tests/own_scope_guard.mjs +10 -0
- package/tests/specialities.mjs +214 -0
- package/tests/speciality_corpus_scope.mjs +97 -0
- package/tests/speciality_db_shape.mjs +124 -0
- package/tests/speciality_injection.mjs +107 -0
|
@@ -130,40 +130,58 @@ module.exports = function buildDoneWhenRouter() {
|
|
|
130
130
|
goal_id: { type: 'integer', min: 1 },
|
|
131
131
|
sort_order: { type: 'integer', min: 0 },
|
|
132
132
|
})) return;
|
|
133
|
-
const version = await db.getVersion(versionId);
|
|
134
|
-
if (!version) return res.fail('version_not_found', { status: 404, message: `No version '${versionId}'.` });
|
|
135
133
|
// bad_goal_id now means exactly what it says: the goal is absent or belongs
|
|
136
134
|
// to another version. (The unauthorized case rendered through this same
|
|
137
135
|
// helper until task 1002614 — see the mask note in authorizeCriterionCreate.)
|
|
138
136
|
const badGoalId = () => res.fail('bad_goal_id', { status: 400, message: `goal_id ${req.body.goal_id} does not exist on version '${versionId}'.` });
|
|
139
|
-
// A goal_id, if given, must exist AND belong to this version.
|
|
140
|
-
let goal = null;
|
|
141
|
-
if (req.body.goal_id != null) {
|
|
142
|
-
goal = await db.getGoal(req.body.goal_id);
|
|
143
|
-
if (!goal || String(goal.version_id) !== String(versionId)) return badGoalId();
|
|
144
|
-
}
|
|
145
|
-
// ADR 0154: the authority wall. Checked AFTER the goal's version linkage so a
|
|
146
|
-
// bad ref is still the clearer 400. Membership is only read when a goal was
|
|
147
|
-
// named and the caller isn't an Archon — a primary-key hit on
|
|
148
|
-
// goal_members (goal_id, builder_id) (migration 160), on a low-frequency route.
|
|
149
|
-
const actorMembership = (goal && req.builder.rank !== 'archon')
|
|
150
|
-
? await db.getGoalMember({ goalId: goal.id, builderId: req.builder.id })
|
|
151
|
-
: null;
|
|
152
|
-
const decision = authorizeCriterionCreate({
|
|
153
|
-
goal, version, actorId: req.builder.id, actorRank: req.builder.rank, actorMembership,
|
|
154
|
-
});
|
|
155
|
-
if (!decision.ok) {
|
|
156
|
-
return res.status(decision.status).json(decision.body);
|
|
157
|
-
}
|
|
158
137
|
try {
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
138
|
+
// RESOLVE, DECIDE and WRITE in ONE transaction, with the goal row LOCKED
|
|
139
|
+
// (task 1003840). Before this, the three facts the decision rests on — the
|
|
140
|
+
// version's status, the goal's owner/version linkage, the actor's
|
|
141
|
+
// membership — were read on the pool, and the INSERT landed afterwards on
|
|
142
|
+
// another connection. Between the two, the goal could be transferred or
|
|
143
|
+
// closed, the membership revoked, or the version frozen, and the write
|
|
144
|
+
// would land on authority that no longer held.
|
|
145
|
+
//
|
|
146
|
+
// The LOCK is the part that closes it. A bare transaction around the same
|
|
147
|
+
// sequence would still read the stale rows; the lock makes a concurrent
|
|
148
|
+
// transfer wait behind this one instead of racing it.
|
|
149
|
+
const outcome = await api.withTx(async (client) => {
|
|
150
|
+
const version = await db.getVersion(versionId, { client, forUpdate: true });
|
|
151
|
+
if (!version) return { fail: ['version_not_found', { status: 404, message: `No version '${versionId}'.` }] };
|
|
152
|
+
// A goal_id, if given, must exist AND belong to this version.
|
|
153
|
+
let goal = null;
|
|
154
|
+
if (req.body.goal_id != null) {
|
|
155
|
+
goal = await db.getGoal(req.body.goal_id, { client, forUpdate: true });
|
|
156
|
+
if (!goal || String(goal.version_id) !== String(versionId)) return { badGoal: true };
|
|
157
|
+
}
|
|
158
|
+
// ADR 0154: the authority wall. Checked AFTER the goal's version linkage so a
|
|
159
|
+
// bad ref is still the clearer 400. Membership is only read when a goal was
|
|
160
|
+
// named and the caller isn't an Archon — a primary-key hit on
|
|
161
|
+
// goal_members (goal_id, builder_id) (migration 160), on a low-frequency route.
|
|
162
|
+
const actorMembership = (goal && req.builder.rank !== 'archon')
|
|
163
|
+
? await db.getGoalMember({ goalId: goal.id, builderId: req.builder.id }, { client })
|
|
164
|
+
: null;
|
|
165
|
+
const decision = authorizeCriterionCreate({
|
|
166
|
+
goal, version, actorId: req.builder.id, actorRank: req.builder.rank, actorMembership,
|
|
167
|
+
});
|
|
168
|
+
if (!decision.ok) return { decision };
|
|
169
|
+
const criterion = await db.createCriterion({
|
|
170
|
+
versionId,
|
|
171
|
+
criterionId: req.body.criterion_id,
|
|
172
|
+
criterionMd: req.body.criterion_md,
|
|
173
|
+
goalId: req.body.goal_id ?? null,
|
|
174
|
+
sortOrder: req.body.sort_order ?? null,
|
|
175
|
+
}, { client });
|
|
176
|
+
return { criterion };
|
|
177
|
+
}, { pool: api.pool });
|
|
178
|
+
// Responses are sent AFTER the transaction resolves, never from inside it:
|
|
179
|
+
// a refusal must roll back rather than commit a half-written unit of work,
|
|
180
|
+
// and a 201 must not be promised before the COMMIT succeeds.
|
|
181
|
+
if (outcome.badGoal) return badGoalId();
|
|
182
|
+
if (outcome.fail) return res.fail(outcome.fail[0], outcome.fail[1]);
|
|
183
|
+
if (outcome.decision) return res.status(outcome.decision.status).json(outcome.decision.body);
|
|
184
|
+
res.status(201).json({ ok: true, criterion: outcome.criterion });
|
|
167
185
|
} catch (err) {
|
|
168
186
|
if (err && err.code === '23505') {
|
|
169
187
|
return res.fail('criterion_exists', { status: 409, message: `criterion_id '${req.body.criterion_id}' already exists on version '${versionId}'.` });
|
|
@@ -294,64 +312,82 @@ module.exports = function buildDoneWhenRouter() {
|
|
|
294
312
|
}
|
|
295
313
|
const id = parseId(req, res, { param: 'criterionId', code: 'bad_criterion_id' });
|
|
296
314
|
if (id === null) return;
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
//
|
|
301
|
-
//
|
|
302
|
-
//
|
|
303
|
-
const
|
|
304
|
-
const
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
315
|
+
// Resolve, authorize and write inside ONE transaction, both goal rows LOCKED
|
|
316
|
+
// (task 1003840) — the same window the CREATE route above had, and the same
|
|
317
|
+
// cure. A move reads authority at BOTH ends, so both ends are locked: losing
|
|
318
|
+
// the race at either would move a criterion under authority that had just
|
|
319
|
+
// been revoked. Locking in a stable order (source before destination) is what
|
|
320
|
+
// keeps two concurrent moves from deadlocking on each other's rows.
|
|
321
|
+
const outcome = await api.withTx(async (client) => {
|
|
322
|
+
const existing = await doneWhen.getCriterion(id, { client });
|
|
323
|
+
if (!existing) return { fail: ['criterion_not_found', 404] };
|
|
324
|
+
const version = await db.getVersion(existing.version_id, { client, forUpdate: true });
|
|
325
|
+
// The ownership wall, resolved exactly as the CREATE route resolves it: the
|
|
326
|
+
// goal + version, and membership read only for a non-Archon. One closure, so
|
|
327
|
+
// both ends of a move are decided by the same code rather than a copy.
|
|
328
|
+
const authorizeFor = async (goal) => {
|
|
329
|
+
const actorMembership = (goal && req.builder.rank !== 'archon')
|
|
330
|
+
? await db.getGoalMember({ goalId: goal.id, builderId: req.builder.id }, { client })
|
|
331
|
+
: null;
|
|
332
|
+
return authorizeCriterionCreate({
|
|
333
|
+
goal, version, actorId: req.builder.id, actorRank: req.builder.rank, actorMembership,
|
|
334
|
+
});
|
|
335
|
+
};
|
|
336
|
+
const targetGoalId = movingGoal ? (req.body.goal_id ?? null) : null;
|
|
337
|
+
|
|
338
|
+
// Lock BOTH ends in ASCENDING GOAL ID, never in the order the route happens
|
|
339
|
+
// to need them (task 1003840, round 2 — the first cut locked source then
|
|
340
|
+
// destination, which is call order, not a canonical one). Two opposite
|
|
341
|
+
// moves, 5→10 and 10→5, would then take the two locks in opposite orders
|
|
342
|
+
// and deadlock; Postgres aborts one rather than corrupting anything, but an
|
|
343
|
+
// avoidable abort is still an outage for whoever loses. A single agreed
|
|
344
|
+
// order means they queue instead.
|
|
345
|
+
const lockIds = [...new Set([
|
|
346
|
+
existing.goal_id != null ? Number(existing.goal_id) : null,
|
|
347
|
+
targetGoalId !== null ? Number(targetGoalId) : null,
|
|
348
|
+
].filter((v) => v != null))].sort((a, b) => a - b);
|
|
349
|
+
const locked = new Map();
|
|
350
|
+
for (const gid of lockIds) locked.set(gid, await db.getGoal(gid, { client, forUpdate: true }));
|
|
351
|
+
|
|
352
|
+
const currentGoal = existing.goal_id != null ? locked.get(Number(existing.goal_id)) ?? null : null;
|
|
353
|
+
const authority = await authorizeFor(currentGoal);
|
|
354
|
+
if (!authority.ok) return { decision: authority };
|
|
314
355
|
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
356
|
+
if (movingGoal) {
|
|
357
|
+
let targetGoal = null;
|
|
358
|
+
if (targetGoalId !== null) {
|
|
359
|
+
targetGoal = locked.get(Number(targetGoalId)) ?? null;
|
|
360
|
+
// Same refusal the CREATE route gives, for the same reason: a criterion may
|
|
361
|
+
// never cross versions, which would put it in one version's rollup and the
|
|
362
|
+
// other's goal at once.
|
|
363
|
+
if (!targetGoal || String(targetGoal.version_id) !== String(existing.version_id)) {
|
|
364
|
+
return { fail: ['bad_goal_id', { status: 400, message: `goal_id ${targetGoalId} does not exist on version '${existing.version_id}'.` }] };
|
|
365
|
+
}
|
|
325
366
|
}
|
|
367
|
+
const destination = await authorizeFor(targetGoal);
|
|
368
|
+
if (!destination.ok) return { decision: destination };
|
|
326
369
|
}
|
|
327
|
-
const destination = await authorizeFor(targetGoal);
|
|
328
|
-
if (!destination.ok) return res.status(destination.status).json(destination.body);
|
|
329
|
-
}
|
|
330
370
|
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
const fromGoalId = existing.goal_id ?? null;
|
|
371
|
+
let criterion = existing;
|
|
372
|
+
let deltas = [];
|
|
373
|
+
let unchanged = false;
|
|
374
|
+
if (changingProse) {
|
|
375
|
+
const refusal = goalEdits.criterionProseRefusal(existing);
|
|
376
|
+
if (refusal) return { refusal };
|
|
377
|
+
const result = await goalEdits.updateCriterionProse({ criterionId: id, criterionMd: req.body.criterion_md }, { client });
|
|
378
|
+
if (!result) return { fail: ['criterion_not_found', 404] };
|
|
379
|
+
criterion = result.criterion;
|
|
380
|
+
deltas = result.deltas;
|
|
381
|
+
unchanged = !!result.unchanged;
|
|
382
|
+
}
|
|
383
|
+
const achievedGoals = [];
|
|
384
|
+
const fromGoalId = existing.goal_id ?? null;
|
|
346
385
|
// A move to where it already is writes nothing at all — not the row, not the
|
|
347
386
|
// audit, not a re-derive. Nothing about the world changed.
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
await goalEdits.recordCriterionGoalMove(
|
|
353
|
-
{ criterionId: id, fromGoalId, toGoalId: targetGoalId }, req
|
|
354
|
-
);
|
|
387
|
+
if (movingGoal && fromGoalId !== targetGoalId) {
|
|
388
|
+
const moved = await db.updateCriterionGoal(id, targetGoalId, { client });
|
|
389
|
+
if (!moved) return { fail: ['criterion_not_found', 404] };
|
|
390
|
+
criterion = moved;
|
|
355
391
|
// BOTH ENDS are re-derived, because a move changes what each of them still
|
|
356
392
|
// needs. The destination is the obvious half — adopting an already-satisfied
|
|
357
393
|
// criterion can be the thing that completes a goal. The SOURCE is the half
|
|
@@ -361,13 +397,41 @@ module.exports = function buildDoneWhenRouter() {
|
|
|
361
397
|
// will ever close again — no later ship re-checks it, because no task under
|
|
362
398
|
// it changed. achieveGoalIfComplete is conservative and idempotent (open
|
|
363
399
|
// goals only, and it never un-achieves), so asking twice is two reads.
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
400
|
+
for (const goalId of [fromGoalId, targetGoalId]) {
|
|
401
|
+
if (goalId === null) continue;
|
|
402
|
+
const achieved = await db.achieveGoalIfComplete({ goalId }, { client });
|
|
403
|
+
if (achieved && achieved.achieved) achievedGoals.push(goalId);
|
|
404
|
+
}
|
|
368
405
|
}
|
|
406
|
+
return { criterion, deltas, unchanged, achievedGoals, moved: movingGoal && fromGoalId !== targetGoalId, fromGoalId, targetGoalId };
|
|
407
|
+
}, { pool: api.pool });
|
|
408
|
+
|
|
409
|
+
// Everything below runs only after COMMIT. The audit writes in particular
|
|
410
|
+
// belong out here: they are announcements of a change that happened, so
|
|
411
|
+
// recording one for a unit of work that rolled back would put an edit in the
|
|
412
|
+
// ledger that never landed — the ledger lying, which is the one thing it
|
|
413
|
+
// must not do.
|
|
414
|
+
if (outcome.fail) return res.fail(outcome.fail[0], outcome.fail[1]);
|
|
415
|
+
if (outcome.decision) return res.status(outcome.decision.status).json(outcome.decision.body);
|
|
416
|
+
if (outcome.refusal) {
|
|
417
|
+
const r = outcome.refusal;
|
|
418
|
+
return res.fail(r.code, r.status, r.message ? { message: r.message } : undefined);
|
|
369
419
|
}
|
|
370
|
-
|
|
420
|
+
if (outcome.deltas && outcome.deltas.length) {
|
|
421
|
+
await goalEdits.recordProseEdit('criterion', id, req, outcome.deltas);
|
|
422
|
+
}
|
|
423
|
+
if (outcome.moved) {
|
|
424
|
+
await goalEdits.recordCriterionGoalMove(
|
|
425
|
+
{ criterionId: id, fromGoalId: outcome.fromGoalId, toGoalId: outcome.targetGoalId }, req
|
|
426
|
+
);
|
|
427
|
+
}
|
|
428
|
+
return res.json({
|
|
429
|
+
ok: true,
|
|
430
|
+
criterion: outcome.criterion,
|
|
431
|
+
deltas: outcome.deltas,
|
|
432
|
+
unchanged: outcome.unchanged,
|
|
433
|
+
achieved_goals: outcome.achievedGoals,
|
|
434
|
+
});
|
|
371
435
|
}, { errorCode: 'criterion_update_failed' }));
|
|
372
436
|
|
|
373
437
|
// Auth: flip a criterion back to unsatisfied (rare; for corrections).
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
|
|
14
14
|
// Carved into modules/memory/ (BV1.R72 / ADR 0091 §2): pool via the doorway.
|
|
15
15
|
const crypto = require('crypto');
|
|
16
|
-
const { pool } = require('../../src/module-api');
|
|
16
|
+
const { pool, resolveOptional } = require('../../src/module-api');
|
|
17
17
|
// ADR 0095 C4: <private>…</private>-fenced content is stripped at ingest so it
|
|
18
18
|
// never lands in the learnings store (nor the recall index that reads it).
|
|
19
19
|
const { stripPrivate } = require('./search-chunker');
|
|
@@ -111,6 +111,20 @@ async function createLearning({
|
|
|
111
111
|
}
|
|
112
112
|
}
|
|
113
113
|
|
|
114
|
+
// task 1003855: once the row lands, offer it to the builder's active speciality
|
|
115
|
+
// for that task's craft (training half (b)). Through the PORT, so this module
|
|
116
|
+
// never requires the specialities module and a disabled one simply means no
|
|
117
|
+
// link. Best-effort by construction: a capture must never fail over it.
|
|
118
|
+
const offerToSpeciality = async (row) => {
|
|
119
|
+
if (!row || !row.id) return;
|
|
120
|
+
try {
|
|
121
|
+
const sp = typeof resolveOptional === 'function' ? resolveOptional('specialities') : null;
|
|
122
|
+
if (sp && typeof sp.recordLearningForTask === 'function') {
|
|
123
|
+
await sp.recordLearningForTask({ learningId: row.id, builderId: row.captured_by, taskId: row.task_id });
|
|
124
|
+
}
|
|
125
|
+
} catch { /* never fail a capture over speciality bookkeeping */ }
|
|
126
|
+
};
|
|
127
|
+
|
|
114
128
|
const { rows } = await pool.query(
|
|
115
129
|
`INSERT INTO learnings (title, body_md, tags, related_files, captured_by,
|
|
116
130
|
source, source_ref, task_id)
|
|
@@ -131,7 +145,12 @@ async function createLearning({
|
|
|
131
145
|
taskId,
|
|
132
146
|
]
|
|
133
147
|
);
|
|
134
|
-
|
|
148
|
+
const created = rows[0] ?? null;
|
|
149
|
+
// Awaited, not fire-and-forget: an unawaited promise here would let the process
|
|
150
|
+
// exit mid-write in a CLI capture, and the helper already swallows its own
|
|
151
|
+
// failures, so awaiting costs one round trip and never fails the capture.
|
|
152
|
+
await offerToSpeciality(created);
|
|
153
|
+
return created;
|
|
135
154
|
}
|
|
136
155
|
|
|
137
156
|
// -------------------------------------------------------------------------
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# @bongos/core — installed platform dependency (not your project's CLAUDE.md)
|
|
2
|
+
|
|
3
|
+
This file is a placeholder shipped inside the **@bongos/core** package. The core's own
|
|
4
|
+
internal agent notes are intentionally omitted from the published artifact so they cannot
|
|
5
|
+
load into your instance's context.
|
|
6
|
+
|
|
7
|
+
**It does not govern this repository.** Your own root `CLAUDE.md`, your `.claude/`
|
|
8
|
+
directory, and the live Bongos database are the source of truth for how work happens here
|
|
9
|
+
and who may do what.
|
|
10
|
+
|
|
11
|
+
You are seeing this only because an agent opened a file under `node_modules/@bongos/core/`.
|
|
12
|
+
Nothing in here needs to be read to use the platform — run `bongos help` for commands.
|
|
13
|
+
|
|
14
|
+
_To work ON the platform core itself, use the standalone @bongos/core source repository,
|
|
15
|
+
not this vendored copy._
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// modules/specialities/db.js — the queries behind role sub-specialities
|
|
4
|
+
// (task 1003855; ADR 0296). The RULES live in specialities.js and are pure; this
|
|
5
|
+
// file is only the reach into Postgres, through the published doorway
|
|
6
|
+
// (src/module-api.js) per ADR 0091 §2 — never a core internal.
|
|
7
|
+
//
|
|
8
|
+
// EVERY FUNCTION TAKES AN OPTIONAL `executor`. That is the modules/lifecycle/
|
|
9
|
+
// db-deps-criteria.js precedent: the seam lets the route-level tests drive these
|
|
10
|
+
// with a fake and assert the SQL shape without a database, which matters here
|
|
11
|
+
// because the adoption path has a uniqueness rule that is easy to get wrong and
|
|
12
|
+
// expensive to discover in production.
|
|
13
|
+
|
|
14
|
+
const { pool } = require('../../src/module-api');
|
|
15
|
+
|
|
16
|
+
const exec = (executor) => executor || ((text, params) => pool.query(text, params));
|
|
17
|
+
|
|
18
|
+
const COLUMNS = `id, slug, name, discipline, summary, contract_md,
|
|
19
|
+
owner_kind, owner_builder_id, owner_project_id,
|
|
20
|
+
visibility, sellable, created_by, created_at, updated_at`;
|
|
21
|
+
|
|
22
|
+
async function createSpeciality(row, executor) {
|
|
23
|
+
const q = exec(executor);
|
|
24
|
+
const r = await q(
|
|
25
|
+
`INSERT INTO specialities_catalog
|
|
26
|
+
(slug, name, discipline, summary, contract_md, owner_kind,
|
|
27
|
+
owner_builder_id, owner_project_id, visibility, sellable, created_by)
|
|
28
|
+
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)
|
|
29
|
+
RETURNING ${COLUMNS}`,
|
|
30
|
+
[row.slug, row.name, row.discipline, row.summary || '', row.contract_md || '',
|
|
31
|
+
row.owner_kind, row.owner_builder_id || null, row.owner_project_id || null,
|
|
32
|
+
row.visibility || 'private', !!row.sellable, row.created_by || null],
|
|
33
|
+
);
|
|
34
|
+
return r.rows[0] || null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function getSpeciality(id, executor) {
|
|
38
|
+
const q = exec(executor);
|
|
39
|
+
const r = await q(`SELECT ${COLUMNS} FROM specialities_catalog WHERE id = $1`, [id]);
|
|
40
|
+
return r.rows[0] || null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// The catalogue read. Deliberately returns rows the viewer may NOT adopt as well
|
|
44
|
+
// as ones they may: the route filters with specialities.canAdopt so the rule
|
|
45
|
+
// lives in one pure place. Widening this SQL without touching canAdopt is the
|
|
46
|
+
// mistake this comment exists to prevent.
|
|
47
|
+
async function listVisibleTo({ builderId, projectIds = [], discipline = null }, executor) {
|
|
48
|
+
const q = exec(executor);
|
|
49
|
+
const params = [builderId, projectIds.length ? projectIds : [null]];
|
|
50
|
+
let sql = `SELECT ${COLUMNS} FROM specialities_catalog
|
|
51
|
+
WHERE visibility = 'public'
|
|
52
|
+
OR (owner_kind = 'builder' AND owner_builder_id = $1)
|
|
53
|
+
OR (owner_kind = 'project' AND visibility = 'project' AND owner_project_id = ANY($2))`;
|
|
54
|
+
if (discipline) { params.push(discipline); sql += ` AND discipline = $${params.length}`; }
|
|
55
|
+
sql += ' ORDER BY name ASC';
|
|
56
|
+
const r = await q(sql, params);
|
|
57
|
+
return r.rows;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// A speciality's DISCIPLINE IS IMMUTABLE and is not in this list on purpose:
|
|
61
|
+
// moving one between crafts would silently re-scope everyone who adopted it, and
|
|
62
|
+
// the adoption rows carry a denormalised copy the migration relies on.
|
|
63
|
+
const UPDATABLE = ['name', 'summary', 'contract_md', 'visibility', 'sellable'];
|
|
64
|
+
|
|
65
|
+
async function updateSpeciality(id, patch, executor) {
|
|
66
|
+
const q = exec(executor);
|
|
67
|
+
const sets = [];
|
|
68
|
+
const params = [];
|
|
69
|
+
for (const f of UPDATABLE) {
|
|
70
|
+
if (patch[f] === undefined) continue;
|
|
71
|
+
params.push(patch[f]);
|
|
72
|
+
sets.push(`${f} = $${params.length}`);
|
|
73
|
+
}
|
|
74
|
+
if (!sets.length) return getSpeciality(id, executor);
|
|
75
|
+
params.push(id);
|
|
76
|
+
const r = await q(
|
|
77
|
+
`UPDATE specialities_catalog SET ${sets.join(', ')}, updated_at = now()
|
|
78
|
+
WHERE id = $${params.length} RETURNING ${COLUMNS}`,
|
|
79
|
+
params,
|
|
80
|
+
);
|
|
81
|
+
return r.rows[0] || null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ── training (a): documents the builder supplied ────────────────────────────
|
|
85
|
+
// ON CONFLICT DO NOTHING against the (speciality_id, content_sha) index: adding
|
|
86
|
+
// the same document twice is a no-op rather than a duplicate that would double
|
|
87
|
+
// its weight in the corpus. Returns null when nothing was inserted, so the route
|
|
88
|
+
// can answer 200-already-there instead of pretending it created something.
|
|
89
|
+
async function addDocument(row, executor) {
|
|
90
|
+
const q = exec(executor);
|
|
91
|
+
const r = await q(
|
|
92
|
+
`INSERT INTO specialities_documents (speciality_id, title, body_md, source, content_sha, added_by)
|
|
93
|
+
VALUES ($1,$2,$3,$4,$5,$6)
|
|
94
|
+
ON CONFLICT (speciality_id, content_sha) DO NOTHING
|
|
95
|
+
RETURNING id, speciality_id, title, source, content_sha, added_by, created_at`,
|
|
96
|
+
[row.speciality_id, row.title, row.body_md, row.source || 'paste', row.content_sha, row.added_by || null],
|
|
97
|
+
);
|
|
98
|
+
return r.rows[0] || null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Metadata only — the bodies are the corpus and can be large. A caller that wants
|
|
102
|
+
// content asks for one document by id.
|
|
103
|
+
async function listDocuments(specialityId, executor) {
|
|
104
|
+
const q = exec(executor);
|
|
105
|
+
const r = await q(
|
|
106
|
+
`SELECT id, speciality_id, title, source, content_sha, added_by, created_at,
|
|
107
|
+
length(body_md) AS body_chars
|
|
108
|
+
FROM specialities_documents WHERE speciality_id = $1 ORDER BY created_at DESC`,
|
|
109
|
+
[specialityId],
|
|
110
|
+
);
|
|
111
|
+
return r.rows;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function getDocument(specialityId, docId, executor) {
|
|
115
|
+
const q = exec(executor);
|
|
116
|
+
const r = await q(
|
|
117
|
+
`SELECT id, speciality_id, title, body_md, source, content_sha, created_at
|
|
118
|
+
FROM specialities_documents WHERE speciality_id = $1 AND id = $2`,
|
|
119
|
+
[specialityId, docId],
|
|
120
|
+
);
|
|
121
|
+
return r.rows[0] || null;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function deleteDocument(specialityId, docId, executor) {
|
|
125
|
+
const q = exec(executor);
|
|
126
|
+
const r = await q(
|
|
127
|
+
'DELETE FROM specialities_documents WHERE speciality_id = $1 AND id = $2 RETURNING id',
|
|
128
|
+
[specialityId, docId],
|
|
129
|
+
);
|
|
130
|
+
return r.rowCount > 0;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ── training (b): what sessions taught it ───────────────────────────────────
|
|
134
|
+
async function linkLearning(specialityId, learningId, executor) {
|
|
135
|
+
const q = exec(executor);
|
|
136
|
+
const r = await q(
|
|
137
|
+
`INSERT INTO specialities_learnings (speciality_id, learning_id) VALUES ($1,$2)
|
|
138
|
+
ON CONFLICT (speciality_id, learning_id) DO NOTHING RETURNING learning_id`,
|
|
139
|
+
[specialityId, learningId],
|
|
140
|
+
);
|
|
141
|
+
return r.rowCount > 0;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async function countTraining(specialityId, executor) {
|
|
145
|
+
const q = exec(executor);
|
|
146
|
+
const r = await q(
|
|
147
|
+
`SELECT (SELECT count(*) FROM specialities_documents WHERE speciality_id = $1) AS documents,
|
|
148
|
+
(SELECT count(*) FROM specialities_learnings WHERE speciality_id = $1) AS learnings`,
|
|
149
|
+
[specialityId],
|
|
150
|
+
);
|
|
151
|
+
const row = r.rows[0] || {};
|
|
152
|
+
return { documentCount: Number(row.documents || 0), learningCount: Number(row.learnings || 0) };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// ── adoption ────────────────────────────────────────────────────────────────
|
|
156
|
+
// Switching craft speciality is DEACTIVATE-THEN-ACTIVATE inside one statement
|
|
157
|
+
// pair, because the partial unique index (builder_id, discipline) WHERE active
|
|
158
|
+
// would otherwise reject the second adoption. Both run on the same executor so a
|
|
159
|
+
// caller may wrap them in a transaction.
|
|
160
|
+
async function deactivateCraft(builderId, discipline, executor) {
|
|
161
|
+
const q = exec(executor);
|
|
162
|
+
await q(
|
|
163
|
+
'UPDATE specialities_adoptions SET active = FALSE WHERE builder_id = $1 AND discipline = $2 AND active',
|
|
164
|
+
[builderId, discipline],
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async function adopt({ builderId, specialityId, discipline }, executor) {
|
|
169
|
+
const q = exec(executor);
|
|
170
|
+
await deactivateCraft(builderId, discipline, executor);
|
|
171
|
+
const r = await q(
|
|
172
|
+
`INSERT INTO specialities_adoptions (builder_id, speciality_id, discipline, active)
|
|
173
|
+
VALUES ($1,$2,$3,TRUE)
|
|
174
|
+
ON CONFLICT (builder_id, speciality_id)
|
|
175
|
+
DO UPDATE SET active = TRUE, discipline = EXCLUDED.discipline, adopted_at = now()
|
|
176
|
+
RETURNING builder_id, speciality_id, discipline, active, adopted_at`,
|
|
177
|
+
[builderId, specialityId, discipline],
|
|
178
|
+
);
|
|
179
|
+
return r.rows[0] || null;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async function abandon(builderId, specialityId, executor) {
|
|
183
|
+
const q = exec(executor);
|
|
184
|
+
const r = await q(
|
|
185
|
+
'UPDATE specialities_adoptions SET active = FALSE WHERE builder_id = $1 AND speciality_id = $2 AND active RETURNING speciality_id',
|
|
186
|
+
[builderId, specialityId],
|
|
187
|
+
);
|
|
188
|
+
return r.rowCount > 0;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// The read the session injection path depends on: the builder's active
|
|
192
|
+
// speciality for a craft, joined to the speciality itself.
|
|
193
|
+
async function activeForCraft(builderId, discipline, executor) {
|
|
194
|
+
const q = exec(executor);
|
|
195
|
+
const r = await q(
|
|
196
|
+
`SELECT s.id, s.slug, s.name, s.discipline, s.summary, s.contract_md
|
|
197
|
+
FROM specialities_adoptions a JOIN specialities_catalog s ON s.id = a.speciality_id
|
|
198
|
+
WHERE a.builder_id = $1 AND a.discipline = $2 AND a.active
|
|
199
|
+
LIMIT 1`,
|
|
200
|
+
[builderId, discipline],
|
|
201
|
+
);
|
|
202
|
+
return r.rows[0] || null;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async function listAdoptions(builderId, executor) {
|
|
206
|
+
const q = exec(executor);
|
|
207
|
+
const r = await q(
|
|
208
|
+
// contract_md is HERE because the session-injection port builds the injected
|
|
209
|
+
// fragment from this row — omitting it silently dropped the expert's own terms
|
|
210
|
+
// and left only the name and summary reaching the session.
|
|
211
|
+
`SELECT s.id, s.slug, s.name, s.discipline, s.summary, s.contract_md, a.active, a.adopted_at
|
|
212
|
+
FROM specialities_adoptions a JOIN specialities_catalog s ON s.id = a.speciality_id
|
|
213
|
+
WHERE a.builder_id = $1 ORDER BY a.adopted_at DESC`,
|
|
214
|
+
[builderId],
|
|
215
|
+
);
|
|
216
|
+
return r.rows;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// The craft of a task, used to decide WHICH speciality a learning belongs to.
|
|
220
|
+
// `tasks` is a CORE table, read the same way builder-settings reads core
|
|
221
|
+
// `builders` — this is not a reach into another module's own tables.
|
|
222
|
+
async function taskDiscipline(taskId, executor) {
|
|
223
|
+
const q = exec(executor);
|
|
224
|
+
const r = await q('SELECT discipline FROM tasks WHERE id = $1', [taskId]);
|
|
225
|
+
return (r.rows[0] && r.rows[0].discipline) || null;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
module.exports = {
|
|
229
|
+
UPDATABLE, taskDiscipline,
|
|
230
|
+
createSpeciality, getSpeciality, listVisibleTo, updateSpeciality,
|
|
231
|
+
addDocument, listDocuments, getDocument, deleteDocument,
|
|
232
|
+
linkLearning, countTraining,
|
|
233
|
+
adopt, abandon, deactivateCraft, activeForCraft, listAdoptions,
|
|
234
|
+
};
|