@substrat-run/adapter-sqlite 0.133.0 → 0.135.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +18 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +167 -56
- package/dist/index.js.map +1 -1
- package/package.json +6 -5
package/dist/index.js
CHANGED
|
@@ -2,7 +2,7 @@ import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync
|
|
|
2
2
|
import { dirname, join } from 'node:path';
|
|
3
3
|
import Database from 'better-sqlite3';
|
|
4
4
|
import { accessLogEntry, adminLogEntry, delegatedReadParams, opsFailureEntry, opsFailureFingerprint, issueEntry, FRESHNESS_HEARTBEAT_MINUTES, sweepRunEntry, modelUsageEntry, ATTACHMENT_ADDED, ATTACHMENT_REMOVED, attachmentRecord, createTenantInput, domainEvent, domainEventInput, exportReadInput, importBatch, importCursorMove, exportsOfManifestJson, importsOfManifestJson, entitlementGrant, entitlementGrantInput, eventId, meterReading, platformRequestInput, platformRequestId, MAX_PENDING_PLATFORM_REQUESTS, identityLink, identityPool, instant, connection, capabilityGrant, connectionGrant, connectionGrantRecord, connectionSecret, systemGrant, systemSwitch, systemSwitchRecord, peerSwitch, verticalCaller, verticalSlug as verticalSlugOf, subjectRef, requestFingerprint, createConnectionInput, projectedConnectionGrant, projectedConnectionKey, moduleManifest, createOrgInput, promotionAcknowledgement, bindAcknowledgement, bindHostnameInput, channelHistoryEntry, hostnameBinding, publishVersionInput, AUTO_ADMISSION_NOTE, routeTarget, registerVerticalInput, vertical as verticalSchema, verticalChannel, verticalVersion, objectRef, entityObjectRef, assertModuleEmittableType, assertKernelAuthoredType, grantRefFromProof, org as orgSchema, orgMembership, principalId, resolvedIdentity, identityMembership, roleDefinition, scope as scopeSchema, tenant as tenantSchema, tenantRole, subjectShredReceipt, SCOPE_TABLE_PAGE_DEFAULT, SCOPE_TABLE_PAGE_MAX, SCOPE_QUERY_ROW_MAX, verticalServingState, callsOfManifestJson, outboundOfManifestJson, listLimitOf, substratError, assertReplayableDump, delegatedReadRecord, ownerTransferAudit, redrainEventsInput, REDRAIN_BATCH, } from '@substrat-run/contracts';
|
|
5
|
-
import { actorOf, asPrincipal, assertAllowed, assertNoSecret, assertPermissionKey, CAPABILITY_DDL, CAPABILITY_EXCHANGE_OPERATION, capabilityAttachmentWriteRefused, capabilityTokenHash, createCapabilityVerbs, createEntityEdgeVerbs, exchangeCapability as exchangeCapabilitySecret, guardSecrets, mintBecomeCapability, plausibleSessionToken, redactSecrets, redactSecretText, moduleLog, consoleLogSink, resolveCapabilitySession, revokeCapabilityAsPlatform, assertReadOnlyQuery, attachmentBlobKey, entitlementDenial, foldMeterReading, guardSpine, guardSqlLimits, DO_SQL_LIMITS, tooManyResultColumns, tooManyTableColumns, parseValidationRecords, resolveScopeRecord, ulid, ISSUE_RETENTION_DAYS, OPS_FAILURE_RETENTION_DAYS, SWEEP_RUN_RETENTION_DAYS, SWEEP_RUNS_INTENT_INDEX, sweepRunsIntentHasKind, MODEL_USAGE_RETENTION_DAYS, foldModelUsage, backoffAt, platformRequestHistoryQuery, platformRequestOf, PLATFORM_REQUEST_COLUMNS, PLATFORM_REQUEST_REDACTION_SQL, platformRequestRedactionParams, platformRequestRedactionQuery, intentPayloadCarriesSubject, redactSubjectJobRuns, JOB_RUN_PATCH_SQL, JOB_STEP_RECORD_SQL, DELIVERY_ERROR_REDACTION_SQL, REDACTED_DELIVERY_NOTE, seatScopeTuple, switchSystemSchedules, admitPeer, collectPeers, peerSeats, resolveVerticalInstanceFrom, switchPeer, peerGrantsStatus, systemGrantsStatus, systemScheduleState, systemSwitchedOff, systemSwitchedOffMessage, tenantSystemSwitchedOffMessage, SYSTEM_SWITCHES_BACKFILL_SQL, SYSTEM_SWITCHES_DDL, forgetSystemSwitchesOf, listSystemSwitchRecords, recordSystemSwitchedOff, recordSystemSwitchedOn, restoreSystemSwitchRecord, scopesSwitchedOffFor, switchedOffModulesOf, inUnitMovesToAudit, staleCarryRevertRow, staleCarryReverts, systemSwitchRecordsOf, systemSwitchesTableExists, withRecorded, VERSION_MIGRATIONS_DDL, splitManifestMigrations, splitVersionMigrationsBatch, versionMigrationsOf, writeVersionMigrations, denialListQuery, denialSummaryQuery, denialTotalsQuery, DENIAL_WINDOW_QUERY, mapDenialRow, mapDenialSummaryBuckets, IMPERSONATION_COLUMNS, IMPERSONATION_DDL, ImpersonationRefused, assertImpersonationWrites, assertModuleEnqueueableKind, assertSessionUsable, impersonationByIdQuery, impersonationListQuery, impersonationRowValues, impersonationStampOf, mapImpersonationRow, newImpersonationSession, resolveRetryPolicy, isSecretBoxConfigured, unconfiguredSecretBox, createSubjectKeys, generateSealingKeyPair, noSealingKeyMessage, openSealed, sealTo, ConnectionSealingKeyUnavailableError, PermissionDenied, createAtomic, NotSearchable, NotListable, listIndexMigrations, listIndexPlans, listQuery, cursorOf, moduleMigrations, isSearchIndexTable, searchIndexDdl, searchIndexMigrations, searchIndexPlans, searchLimit, searchMatchExpression, searchQuery, entityVersionQuery, entityVersionOf, assertIfMatch, OUTBOX_ENTITY_INDEX, SCHEDULE_STATE_DDL, SCHEDULE_STATE_REBUILD, scheduleStateHasKind, VERTICAL_EVENTS_DDL, EXPORT_HOPS_SQL, IMPORT_CURSORS_SQL, IMPORT_CURSOR_OF_SQL, OUTBOX_MARK_SQL, emittedSinceQuery, emittedReportOf, EMITTED_REPORT_CAP, exportedSinceQuery, IMPORT_CURSOR_ADVANCE_SQL, IMPORT_RECORD_SQL, moveImportCursor, importCursorSourceOf, exportBreaksOf, exportBreakRefusal, bindExportBreaksOf, bindExportBreakRefusal, CrossVerticalRegistry, exportReadPlan, exportReadQuery, planExportBatch, withheldNote, JOB_RUN_DDL, jobRunListLimit, jobRunOf, runDueJobRuns, startJobRun, IDEMPOTENCY_DDL, REFUSALS_DDL, refusalInsert, refusedTransitionOf, assertIdempotencyKey, idempotencyLookupQuery, idempotencyPruneStatement, idempotencyRecordStatement, idempotencyOptedOutMessage, replayFor, globalFetch, createUlid, facetEvents, readHistory, walkEventCause, walkEventEffects, assertRedrainWindow, readInvocation, readDeadLetters, readLifecycleFlow, domainEventOf, readUndrainedOutbox, undrainedEventsOf, connectorCallRecord, noopConnectorCallRecorder, recordConnectorCall, settleConnectionUse, unknownRoleError, } from '@substrat-run/kernel';
|
|
5
|
+
import { actorOf, asPrincipal, assertAllowed, assertNoSecret, assertPermissionKey, CAPABILITY_DDL, CAPABILITY_EXCHANGE_OPERATION, capabilityAttachmentWriteRefused, capabilityTokenHash, createCapabilityVerbs, createEntityEdgeVerbs, exchangeCapability as exchangeCapabilitySecret, guardSecrets, mintBecomeCapability, plausibleSessionToken, readCapabilityPage, redactSecrets, redactSecretText, moduleLog, consoleLogSink, resolveCapabilitySession, revokeCapabilityAsPlatform, assertReadOnlyQuery, attachmentBlobKey, attachmentSha256, entitlementDenial, requiredEntitlementFor, foldMeterReading, guardSpine, guardSqlLimits, DO_SQL_LIMITS, tooManyResultColumns, tooManyTableColumns, parseValidationRecords, resolveScopeRecord, ulid, ISSUE_RETENTION_DAYS, telemetryRetentionStatements, assertRowLimit, assertRowOffset, OPS_FAILURE_RETENTION_DAYS, SWEEP_RUN_RETENTION_DAYS, SWEEP_RUNS_INTENT_INDEX, sweepRunsIntentHasKind, MODEL_USAGE_RETENTION_DAYS, foldModelUsage, backoffAt, platformRequestHistoryQuery, platformRequestOf, PLATFORM_REQUEST_COLUMNS, PLATFORM_REQUEST_REDACTION_SQL, platformRequestRedactionParams, platformRequestRedactionQuery, intentPayloadCarriesSubject, redactSubjectJobRuns, redactSubjectScopeText, redactSubjectDirectoryText, ISSUE_EXEMPLAR_OWNER_BACKFILL_SQL, issueExemplarOwner, JOB_RUN_PATCH_SQL, JOB_STEP_RECORD_SQL, DELIVERY_ERROR_REDACTION_SQL, REDACTED_DELIVERY_NOTE, seatScopeTuple, switchSystemSchedules, admitPeer, collectPeers, peerSeats, resolveVerticalInstanceFrom, switchPeer, peerGrantsStatus, systemGrantsStatus, systemScheduleState, systemSwitchedOff, systemSwitchedOffMessage, tenantSystemSwitchedOffMessage, SYSTEM_SWITCHES_BACKFILL_SQL, SYSTEM_SWITCHES_DDL, forgetSystemSwitchesOf, listSystemSwitchRecords, recordSystemSwitchedOff, recordSystemSwitchedOn, restoreSystemSwitchRecord, scopesSwitchedOffFor, switchedOffModulesOf, inUnitMovesToAudit, staleCarryRevertRow, staleCarryReverts, systemSwitchRecordsOf, systemSwitchesTableExists, withRecorded, VERSION_MIGRATIONS_DDL, splitManifestMigrations, splitVersionMigrationsBatch, versionMigrationsOf, writeVersionMigrations, denialListQuery, refusalListQuery, mapRefusalRow, denialSummaryQuery, denialTotalsQuery, DENIAL_WINDOW_QUERY, mapDenialRow, mapDenialSummaryBuckets, IMPERSONATION_COLUMNS, IMPERSONATION_DDL, ImpersonationRefused, assertImpersonationWrites, assertModuleEnqueueableKind, assertSessionUsable, impersonationByIdQuery, impersonationListQuery, impersonationRowValues, impersonationStampOf, mapImpersonationRow, newImpersonationSession, resolveRetryPolicy, isSecretBoxConfigured, unconfiguredSecretBox, createSubjectKeys, generateSealingKeyPair, noSealingKeyMessage, openSealed, sealTo, ConnectionSealingKeyUnavailableError, PermissionDenied, createAtomic, NotSearchable, NotListable, listIndexMigrations, listIndexPlans, listQuery, cursorOf, moduleMigrations, isSearchIndexTable, searchIndexDdl, searchIndexMigrations, searchIndexPlans, searchLimit, searchMatchExpression, searchQuery, ATTACHMENT_TEXT_DDL, assertAttachmentExtractors, assertJobRegistrable, attachmentRecordOfRow, attachmentTextJob, enqueueAttachmentText, isAttachmentTextRun, reconcileAttachmentText, recordAttachmentText, searchAttachments, entityVersionQuery, entityVersionOf, assertIfMatch, OUTBOX_ENTITY_INDEX, SCHEDULE_STATE_DDL, SCHEDULE_STATE_REBUILD, scheduleStateHasKind, VERTICAL_EVENTS_DDL, EXPORT_HOPS_SQL, IMPORT_CURSORS_SQL, IMPORT_CURSOR_OF_SQL, OUTBOX_MARK_SQL, emittedSinceQuery, emittedReportOf, EMITTED_REPORT_CAP, exportedSinceQuery, IMPORT_CURSOR_ADVANCE_SQL, IMPORT_RECORD_SQL, moveImportCursor, importCursorSourceOf, exportBreaksOf, exportBreakRefusal, bindExportBreaksOf, bindExportBreakRefusal, CrossVerticalRegistry, exportReadPlan, exportReadQuery, planExportBatch, withheldNote, JOB_RUN_DDL, jobRunListLimit, jobRunOf, runDueJobRuns, startJobRun, IDEMPOTENCY_DDL, REFUSALS_DDL, refusalInsert, refusedTransitionOf, assertIdempotencyKey, idempotencyLookupQuery, idempotencyPruneStatement, idempotencyRecordStatement, idempotencyOptedOutMessage, replayFor, globalFetch, createUlid, facetEvents, readHistory, walkEventCause, walkEventEffects, assertRedrainWindow, readInvocation, readDeadLetters, readLifecycleFlow, domainEventOf, readUndrainedOutbox, undrainedEventsOf, connectorCallRecord, noopConnectorCallRecorder, recordConnectorCall, settleConnectionUse, unknownRoleError, } from '@substrat-run/kernel';
|
|
6
6
|
import { attributedHost } from '@substrat-run/kernel';
|
|
7
7
|
import { LEGACY_SCOPE_ROWS_BACKFILL, assertNoSpineReference, assertSpineTablesBuilt, dumpRowsInsert, isSpineTable, loadDirectoryDump, repointScopeGrants, spineColumnAdditions } from '@substrat-run/kernel';
|
|
8
8
|
import { ScopeActor } from './actor.js';
|
|
@@ -295,6 +295,9 @@ const KERNEL_DDL = `
|
|
|
295
295
|
);
|
|
296
296
|
CREATE INDEX IF NOT EXISTS _substrat_attachments_entity
|
|
297
297
|
ON _substrat_attachments (entity_type, entity_id);
|
|
298
|
+
-- #1575: attachment text and its FTS5 index, shared from @substrat-run/kernel. After
|
|
299
|
+
-- _substrat_attachments, whose delete trigger it adds.
|
|
300
|
+
${ATTACHMENT_TEXT_DDL}
|
|
298
301
|
-- #901: an entity's version is the ULID of the last event about it, so
|
|
299
302
|
-- MAX(id) per (entity_type, entity_id) is the read. The id column sits last so
|
|
300
303
|
-- SQLite walks to the end of the matched range instead of aggregating over it.
|
|
@@ -366,7 +369,7 @@ function keysetTail(where, params, key, page, defaultOrder = 'asc') {
|
|
|
366
369
|
let tail = ` ORDER BY ${key} ${order}`;
|
|
367
370
|
if (page?.limit !== undefined) {
|
|
368
371
|
tail += ' LIMIT ?';
|
|
369
|
-
params.push(page.limit);
|
|
372
|
+
params.push(assertRowLimit('limit', page.limit));
|
|
370
373
|
}
|
|
371
374
|
return tail;
|
|
372
375
|
}
|
|
@@ -513,7 +516,8 @@ export class SqliteScopeHost {
|
|
|
513
516
|
listPlans = new Map();
|
|
514
517
|
/** entityType → the declared attachment gate (#473): read key + write key (default: read). */
|
|
515
518
|
attachmentTargets = new Map();
|
|
516
|
-
/** operation name →
|
|
519
|
+
/** operation name → who binds it: the owning module, its entitlementKey and its declared
|
|
520
|
+
* schedules — the §4.3 gate's input, resolved per invoke by `requiredEntitlementFor` (#1654). */
|
|
517
521
|
operationEntitlement = new Map();
|
|
518
522
|
/**
|
|
519
523
|
* #893: name → the declared input schema, parsed before guards and handler.
|
|
@@ -560,6 +564,8 @@ export class SqliteScopeHost {
|
|
|
560
564
|
onBehalfOf = null;
|
|
561
565
|
systemPrincipal = principalId.parse(ulid());
|
|
562
566
|
secretBox;
|
|
567
|
+
/** The parsers attachment text is extracted with (K-43); the host's own, never imported here. */
|
|
568
|
+
attachmentExtractors;
|
|
563
569
|
fetchImpl;
|
|
564
570
|
connectorCalls;
|
|
565
571
|
clock;
|
|
@@ -573,6 +579,8 @@ export class SqliteScopeHost {
|
|
|
573
579
|
// process-wide `ulid()` that every other id here still uses.
|
|
574
580
|
constructor(options) {
|
|
575
581
|
this.secretBox = options.secretBox ?? unconfiguredSecretBox;
|
|
582
|
+
assertAttachmentExtractors(options.attachmentExtractors ?? []);
|
|
583
|
+
this.attachmentExtractors = options.attachmentExtractors ?? [];
|
|
576
584
|
this.fetchImpl = options.fetch ?? globalFetch;
|
|
577
585
|
this.connectorCalls = options.connectorCalls ?? noopConnectorCallRecorder;
|
|
578
586
|
this.clock = options.clock ?? (() => instant.parse(new Date().toISOString()));
|
|
@@ -1113,6 +1121,12 @@ export class SqliteScopeHost {
|
|
|
1113
1121
|
first_seen TEXT NOT NULL,
|
|
1114
1122
|
last_seen TEXT NOT NULL,
|
|
1115
1123
|
last_message TEXT NOT NULL,
|
|
1124
|
+
-- #1632: whose ops failure last_message was copied from, written in the same
|
|
1125
|
+
-- statement as the message: last_owner_kind is 'tenant' (last_tenant_id names it) or
|
|
1126
|
+
-- 'platform' (the platform's own row; last_tenant_id NULL). NULL kind = unknown, an
|
|
1127
|
+
-- issue from before the columns that no retained row attributes.
|
|
1128
|
+
last_tenant_id TEXT,
|
|
1129
|
+
last_owner_kind TEXT,
|
|
1116
1130
|
last_vertical TEXT,
|
|
1117
1131
|
last_version TEXT,
|
|
1118
1132
|
resolved_version TEXT,
|
|
@@ -1517,11 +1531,16 @@ export class SqliteScopeHost {
|
|
|
1517
1531
|
`${[...unboundOptOuts].sort().join(', ')} — an opt-out on nothing reads as an ` +
|
|
1518
1532
|
'exclusion someone decided, of an operation that does not exist');
|
|
1519
1533
|
}
|
|
1534
|
+
const entitlementBinding = {
|
|
1535
|
+
moduleId: manifest.id,
|
|
1536
|
+
entitlementKey: manifest.entitlementKey,
|
|
1537
|
+
scheduledOperations: new Set((manifest.schedules ?? []).map((sch) => sch.operation)),
|
|
1538
|
+
};
|
|
1520
1539
|
for (const [name, handler] of Object.entries(registration.operations ?? {})) {
|
|
1521
1540
|
this.defineOperation(name, handler);
|
|
1522
1541
|
// Record which SKU flag gates this operation (§4.3). Bare defineOperation
|
|
1523
1542
|
// bindings (tests, glue) carry no manifest and stay ungated.
|
|
1524
|
-
this.operationEntitlement.set(name,
|
|
1543
|
+
this.operationEntitlement.set(name, entitlementBinding);
|
|
1525
1544
|
// #893: withdrawal removes the binding, so the schema follows the handler
|
|
1526
1545
|
// rather than the name — a withdrawn operation has nothing to parse for.
|
|
1527
1546
|
const schema = declaredInputs[name];
|
|
@@ -1919,21 +1938,6 @@ export class SqliteScopeHost {
|
|
|
1919
1938
|
}
|
|
1920
1939
|
return gate;
|
|
1921
1940
|
};
|
|
1922
|
-
const rowToRecord = (row) => attachmentRecord.parse({
|
|
1923
|
-
id: row.id,
|
|
1924
|
-
entity: { entityType: row.entity_type, entityId: row.entity_id },
|
|
1925
|
-
filename: row.filename,
|
|
1926
|
-
contentType: row.content_type,
|
|
1927
|
-
size: row.size,
|
|
1928
|
-
sha256: row.sha256,
|
|
1929
|
-
visibility: row.visibility,
|
|
1930
|
-
createdBy: row.created_by,
|
|
1931
|
-
createdAt: row.created_at,
|
|
1932
|
-
});
|
|
1933
|
-
const sha256Hex = async (body) => {
|
|
1934
|
-
const digest = await globalThis.crypto.subtle.digest('SHA-256', body);
|
|
1935
|
-
return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, '0')).join('');
|
|
1936
|
-
};
|
|
1937
1941
|
// One attachment mutation/read = one serialized scope task, transactional exactly
|
|
1938
1942
|
// like an operation invoke (including K-35 denial recording and prompt dispatch of
|
|
1939
1943
|
// the events it emitted).
|
|
@@ -2011,7 +2015,7 @@ export class SqliteScopeHost {
|
|
|
2011
2015
|
filename: input.filename,
|
|
2012
2016
|
contentType: input.contentType,
|
|
2013
2017
|
size: input.body.byteLength,
|
|
2014
|
-
sha256: await
|
|
2018
|
+
sha256: await attachmentSha256(input.body),
|
|
2015
2019
|
visibility: input.visibility,
|
|
2016
2020
|
createdBy: creator.id,
|
|
2017
2021
|
createdAt: new Date().toISOString(),
|
|
@@ -2036,6 +2040,9 @@ export class SqliteScopeHost {
|
|
|
2036
2040
|
piiClass: 'none',
|
|
2037
2041
|
payload: { attachment: record },
|
|
2038
2042
|
});
|
|
2043
|
+
// #1575: queue its text extraction in the same transaction — a `pending` row
|
|
2044
|
+
// and a job run. Nothing is extracted here, so nothing here can fail the upload.
|
|
2045
|
+
enqueueAttachmentText(spineSql(rt.db), record.id, ulid(), this.clock());
|
|
2039
2046
|
return record;
|
|
2040
2047
|
});
|
|
2041
2048
|
}
|
|
@@ -2054,7 +2061,7 @@ export class SqliteScopeHost {
|
|
|
2054
2061
|
.prepare(`SELECT * FROM _substrat_attachments WHERE entity_type = ? AND entity_id = ?
|
|
2055
2062
|
ORDER BY id DESC`)
|
|
2056
2063
|
.all(entity.entityType, entity.entityId);
|
|
2057
|
-
return rows.map(
|
|
2064
|
+
return rows.map(attachmentRecordOfRow);
|
|
2058
2065
|
}),
|
|
2059
2066
|
open: async (attachmentId) => {
|
|
2060
2067
|
const record = await guarded('attachments.open', async (ctx) => {
|
|
@@ -2065,11 +2072,11 @@ export class SqliteScopeHost {
|
|
|
2065
2072
|
return null;
|
|
2066
2073
|
if (opts.admitByEvent !== undefined) {
|
|
2067
2074
|
admitByDelivery(rt, opts.admitByEvent, row);
|
|
2068
|
-
return
|
|
2075
|
+
return attachmentRecordOfRow(row);
|
|
2069
2076
|
}
|
|
2070
2077
|
const gate = targetGate(row.entity_type);
|
|
2071
2078
|
assertAllowed(await ctx.check(gate.read, { entityType: row.entity_type, entityId: row.entity_id }));
|
|
2072
|
-
return
|
|
2079
|
+
return attachmentRecordOfRow(row);
|
|
2073
2080
|
});
|
|
2074
2081
|
if (!record)
|
|
2075
2082
|
return null;
|
|
@@ -2078,7 +2085,7 @@ export class SqliteScopeHost {
|
|
|
2078
2085
|
throw new Error(`attachment ${record.id}: bytes missing from the blob store — the metadata row ` +
|
|
2079
2086
|
`survived something the object did not (rewind/reap); see the #473 integrity notes`);
|
|
2080
2087
|
}
|
|
2081
|
-
if ((await
|
|
2088
|
+
if ((await attachmentSha256(obj.body)) !== record.sha256) {
|
|
2082
2089
|
throw new Error(`attachment ${record.id}: bytes do not match the recorded sha256`);
|
|
2083
2090
|
}
|
|
2084
2091
|
return { record, body: obj.body, contentType: obj.contentType ?? record.contentType };
|
|
@@ -2098,7 +2105,7 @@ export class SqliteScopeHost {
|
|
|
2098
2105
|
const gate = targetGate(row.entity_type);
|
|
2099
2106
|
assertAllowed(await ctx.check(gate.write, { entityType: row.entity_type, entityId: row.entity_id }));
|
|
2100
2107
|
rt.db.prepare('DELETE FROM _substrat_attachments WHERE id = ?').run(attachmentId);
|
|
2101
|
-
const record =
|
|
2108
|
+
const record = attachmentRecordOfRow(row);
|
|
2102
2109
|
kernelEmit(ctx, {
|
|
2103
2110
|
type: ATTACHMENT_REMOVED,
|
|
2104
2111
|
schemaVersion: 1,
|
|
@@ -2114,6 +2121,18 @@ export class SqliteScopeHost {
|
|
|
2114
2121
|
await store.delete(attachmentBlobKey(rt.scopeId, removed.id)).catch(() => { });
|
|
2115
2122
|
return removed;
|
|
2116
2123
|
},
|
|
2124
|
+
// #1575: extracted text, gated per hit by the check `open` makes, before the limit.
|
|
2125
|
+
search: async (term, options) => {
|
|
2126
|
+
if (opts.admitByEvent !== undefined) {
|
|
2127
|
+
// The delivery-admitted surface reads the ONE attachment its delivery names; a
|
|
2128
|
+
// search would reach past it. Nothing builds this surface for anything but `open`.
|
|
2129
|
+
throw new Error('attachments.search is not available on a delivery-scoped surface');
|
|
2130
|
+
}
|
|
2131
|
+
// The term is judged before the turn: a refusal costs no queue slot.
|
|
2132
|
+
searchMatchExpression(term, 'prefix');
|
|
2133
|
+
// Authorized first, as this surface's subject, then matched over readable owners only.
|
|
2134
|
+
return guarded('attachments.search', (ctx) => searchAttachments(spineSql(rt.db), { targets: this.attachmentTargets, check: (permission, entity) => ctx.check(permission, entity) }, term, searchLimit(options?.limit)));
|
|
2135
|
+
},
|
|
2117
2136
|
};
|
|
2118
2137
|
}
|
|
2119
2138
|
async importScope(actor, input, dump) {
|
|
@@ -2224,6 +2243,10 @@ export class SqliteScopeHost {
|
|
|
2224
2243
|
if (present.has(plan.table))
|
|
2225
2244
|
db.exec(searchIndexDdl(plan));
|
|
2226
2245
|
}
|
|
2246
|
+
// #1575: attachment text is not in a dump, so a load left it as it was. Drop the
|
|
2247
|
+
// text of attachments the dump did not bring back, and queue extraction for those
|
|
2248
|
+
// it brought back without text — the bytes decide what that run finds.
|
|
2249
|
+
reconcileAttachmentText(spineSql(db), ulid, this.clock());
|
|
2227
2250
|
// Re-point scope-level grants at the scope they now live in. They are written as
|
|
2228
2251
|
// `object = scope:<scopeId>`, so a fork, a restore into a different scope, or #286's
|
|
2229
2252
|
// migration onto a stable script name all land rows naming a scope that is not this
|
|
@@ -3155,7 +3178,13 @@ export class SqliteScopeHost {
|
|
|
3155
3178
|
// only if the tenant holds its SKU flag. Checked per invoke — the simple,
|
|
3156
3179
|
// uncached path (K-OQ5); a DO-cached variant is a later benchmark call.
|
|
3157
3180
|
// Fails closed the same way withdrawal does: the operation is unavailable.
|
|
3158
|
-
|
|
3181
|
+
//
|
|
3182
|
+
// EXCEPTION (#1654): a module's own declared schedule, through the system door,
|
|
3183
|
+
// demands no SKU — its `system:<moduleId>` grant is the switch, as it already is
|
|
3184
|
+
// for permissions (#383). The kernel's `requiredEntitlementFor` holds the rule and
|
|
3185
|
+
// why, and the Cloudflare coordinator runs the same predicate. Every other door
|
|
3186
|
+
// (principal, connection, capability, peer, impersonation) is gated as before.
|
|
3187
|
+
const requiredKey = requiredEntitlementFor(operation, this.operationEntitlement.get(operation), authority.kind === 'system' ? authority.id : undefined);
|
|
3159
3188
|
if (requiredKey && !this.tenantHoldsEntitlement(tenantId, requiredKey)) {
|
|
3160
3189
|
return Promise.reject(substratError('not_found', entitlementDenial(operation, requiredKey, this.heldEntitlements(tenantId))));
|
|
3161
3190
|
}
|
|
@@ -3571,6 +3600,8 @@ export class SqliteScopeHost {
|
|
|
3571
3600
|
return rt.actor.enqueue(() => this.dispatchExecutors(rt, null));
|
|
3572
3601
|
}
|
|
3573
3602
|
registerJob(moduleId, name, handler, retry) {
|
|
3603
|
+
// #1575: the kernel's own jobs are dispatched before this registry is read.
|
|
3604
|
+
assertJobRegistrable(moduleId, name);
|
|
3574
3605
|
const key = `${moduleId}/${name}`;
|
|
3575
3606
|
if (this.jobs.has(key))
|
|
3576
3607
|
throw new Error(`job '${key}' is already registered`);
|
|
@@ -3674,6 +3705,27 @@ export class SqliteScopeHost {
|
|
|
3674
3705
|
}),
|
|
3675
3706
|
};
|
|
3676
3707
|
}
|
|
3708
|
+
/**
|
|
3709
|
+
* The extraction job's adapter half (#1575): the record and the bytes with no permission
|
|
3710
|
+
* gate — extraction is the kernel's own derivation, and the person-facing gate is at
|
|
3711
|
+
* search — and the outcome written in a turn of its own, like every job store write.
|
|
3712
|
+
*/
|
|
3713
|
+
attachmentTextHandler(rt) {
|
|
3714
|
+
return attachmentTextJob({
|
|
3715
|
+
record: (attachmentId) => rt.actor.enqueue(() => {
|
|
3716
|
+
const row = rt.db.prepare('SELECT * FROM _substrat_attachments WHERE id = ?').get(attachmentId);
|
|
3717
|
+
return row ? attachmentRecordOfRow(row) : null;
|
|
3718
|
+
}),
|
|
3719
|
+
bytes: async (record) => {
|
|
3720
|
+
const scope = this.directory.prepare('SELECT vertical FROM scopes WHERE scope_id = ?').get(rt.scopeId);
|
|
3721
|
+
const store = this.attachmentStore(rt.tenantId, scope?.vertical ?? null);
|
|
3722
|
+
return (await store.get(attachmentBlobKey(rt.scopeId, record.id)))?.body ?? null;
|
|
3723
|
+
},
|
|
3724
|
+
write: (attachmentId, outcome) => rt.actor.enqueue(() => recordAttachmentText(spineSql(rt.db), attachmentId, outcome, this.clock())),
|
|
3725
|
+
},
|
|
3726
|
+
// K-43: the host's parsers, handed in — this adapter imports none.
|
|
3727
|
+
this.attachmentExtractors);
|
|
3728
|
+
}
|
|
3677
3729
|
async startJobRun(tenantId, scopeId, input) {
|
|
3678
3730
|
const rt = this.runtime(tenantId, scopeId);
|
|
3679
3731
|
await this.applyPendingMigrations(rt);
|
|
@@ -3682,9 +3734,12 @@ export class SqliteScopeHost {
|
|
|
3682
3734
|
async runDueJobs(tenantId, scopeId, options) {
|
|
3683
3735
|
const rt = this.runtime(tenantId, scopeId);
|
|
3684
3736
|
await this.applyPendingMigrations(rt);
|
|
3737
|
+
// #1575: the kernel's extraction job is this host's own, bound to this scope — no
|
|
3738
|
+
// deployment registers it, and none can shadow it.
|
|
3739
|
+
const attachmentText = { handler: this.attachmentTextHandler(rt) };
|
|
3685
3740
|
return runDueJobRuns({
|
|
3686
3741
|
store: this.jobStore(rt),
|
|
3687
|
-
handlerFor: (run) => this.jobs.get(`${run.module_id}/${run.job}`),
|
|
3742
|
+
handlerFor: (run) => isAttachmentTextRun(run) ? attachmentText : this.jobs.get(`${run.module_id}/${run.job}`),
|
|
3688
3743
|
now: this.clock,
|
|
3689
3744
|
openScope: (run) => this.getSystemScope(run.module_id, tenantId, scopeId),
|
|
3690
3745
|
maxPasses: options?.maxPasses,
|
|
@@ -4060,7 +4115,7 @@ export class SqliteScopeHost {
|
|
|
4060
4115
|
` ORDER BY id ${order}`;
|
|
4061
4116
|
if (filter?.limit !== undefined) {
|
|
4062
4117
|
sql += ' LIMIT ?';
|
|
4063
|
-
params.push(filter.limit);
|
|
4118
|
+
params.push(assertRowLimit('limit', filter.limit));
|
|
4064
4119
|
}
|
|
4065
4120
|
const rows = this.directory.prepare(sql).all(...params);
|
|
4066
4121
|
return rows.map((r) => modelUsageEntryOf(r));
|
|
@@ -4415,7 +4470,7 @@ export class SqliteScopeHost {
|
|
|
4415
4470
|
// private vertical self-admit, and what lets a LISTED vertical's builder keep
|
|
4416
4471
|
// previewing their own new code (issue #509 ask (d)).
|
|
4417
4472
|
if (v.admission !== 'admitted' && scope.kind !== 'preview') {
|
|
4418
|
-
throw
|
|
4473
|
+
throw substratError('conflict', `version ${versionId} is ${v.admission}, not admitted — it cannot be bound to a scope`);
|
|
4419
4474
|
}
|
|
4420
4475
|
return { v, scope };
|
|
4421
4476
|
};
|
|
@@ -4828,7 +4883,7 @@ export class SqliteScopeHost {
|
|
|
4828
4883
|
` ORDER BY tenant_id ${order}, role_key ${order}`;
|
|
4829
4884
|
if (filter?.limit !== undefined) {
|
|
4830
4885
|
sql += ' LIMIT ?';
|
|
4831
|
-
params.push(filter.limit);
|
|
4886
|
+
params.push(assertRowLimit('limit', filter.limit));
|
|
4832
4887
|
}
|
|
4833
4888
|
const rows = this.directory.prepare(sql).all(...params);
|
|
4834
4889
|
// Parsed, not cast: `permissions` is a JSON blob in a TEXT column, so the
|
|
@@ -5038,6 +5093,15 @@ export class SqliteScopeHost {
|
|
|
5038
5093
|
revoked: true,
|
|
5039
5094
|
});
|
|
5040
5095
|
},
|
|
5096
|
+
// #1686 — the operator's read of the directory `ctx.capabilities.list` reads. The
|
|
5097
|
+
// denial log's discipline: `scopeReadDbFor` is the K-3 cross-check, the read is the
|
|
5098
|
+
// kernel's one (`readCapabilityPage`: no `token_hash` column in it), and the read leaves a K-24 row.
|
|
5099
|
+
listCapabilities: async (actor, tenantId, scopeId, filter) => {
|
|
5100
|
+
const db = this.scopeReadDbFor(tenantId, scopeId);
|
|
5101
|
+
const page = readCapabilityPage(spineSql(db), filter);
|
|
5102
|
+
this.recordAccess(actor, 'listCapabilities', { tenantId, scopeId }, filter ?? null, page.entries.length);
|
|
5103
|
+
return page;
|
|
5104
|
+
},
|
|
5041
5105
|
grantToOrg: async (actor, orgId, permission, node, entity) => {
|
|
5042
5106
|
// The org must exist in the node's tenant. A grant to a phantom org is
|
|
5043
5107
|
// worse than an error: it looks applied, resolves for nobody, and shows up
|
|
@@ -5287,7 +5351,7 @@ export class SqliteScopeHost {
|
|
|
5287
5351
|
return;
|
|
5288
5352
|
}
|
|
5289
5353
|
if (existing.ownerTenant !== parsed.ownerTenant) {
|
|
5290
|
-
throw
|
|
5354
|
+
throw substratError('conflict', `vertical '${parsed.slug}' is owned by ${existing.ownerTenant ?? 'the platform'}, not ${parsed.ownerTenant ?? 'the platform'}`);
|
|
5291
5355
|
}
|
|
5292
5356
|
throw new Error(`vertical '${parsed.slug}' is already registered as ${existing.source}`);
|
|
5293
5357
|
}
|
|
@@ -5368,7 +5432,7 @@ export class SqliteScopeHost {
|
|
|
5368
5432
|
.get(slug);
|
|
5369
5433
|
const prodVersion = prod ? readVersion(prod.version_id) : undefined;
|
|
5370
5434
|
if (prodVersion?.admissionNote === AUTO_ADMISSION_NOTE) {
|
|
5371
|
-
throw
|
|
5435
|
+
throw substratError('conflict', `vertical '${slug}' prod version ${prodVersion.id} is auto-admitted (private self-serve) — ` +
|
|
5372
5436
|
`a staff admit must vouch for it before listing`);
|
|
5373
5437
|
}
|
|
5374
5438
|
}
|
|
@@ -5478,7 +5542,7 @@ export class SqliteScopeHost {
|
|
|
5478
5542
|
if (!v)
|
|
5479
5543
|
throw substratError('not_found', `unknown version ${versionId}`);
|
|
5480
5544
|
if (v.admission === 'admitted') {
|
|
5481
|
-
throw
|
|
5545
|
+
throw substratError('conflict', `version ${versionId} is already admitted — it may be bound`);
|
|
5482
5546
|
}
|
|
5483
5547
|
if (v.admission === 'rejected')
|
|
5484
5548
|
return; // idempotent
|
|
@@ -5507,10 +5571,10 @@ export class SqliteScopeHost {
|
|
|
5507
5571
|
if (!incoming)
|
|
5508
5572
|
throw substratError('not_found', `unknown version ${versionId}`);
|
|
5509
5573
|
if (incoming.verticalSlug !== verticalSlug) {
|
|
5510
|
-
throw
|
|
5574
|
+
throw substratError('conflict', `version ${versionId} belongs to '${incoming.verticalSlug}'`);
|
|
5511
5575
|
}
|
|
5512
5576
|
if (incoming.admission !== 'admitted') {
|
|
5513
|
-
throw
|
|
5577
|
+
throw substratError('conflict', `version ${versionId} is ${incoming.admission}, not admitted — it cannot be promoted`);
|
|
5514
5578
|
}
|
|
5515
5579
|
const current = this.directory
|
|
5516
5580
|
.prepare('SELECT version_id FROM vertical_channels WHERE vertical_slug = ? AND channel = ?')
|
|
@@ -5786,7 +5850,7 @@ export class SqliteScopeHost {
|
|
|
5786
5850
|
// The kernel's read, shared with the DO: the envelope and the columns lifted beside
|
|
5787
5851
|
// it (#1242, #1237) are decoded once, and a row that will not decode is stepped over
|
|
5788
5852
|
// rather than stalling the scope on it (#1636).
|
|
5789
|
-
const read = readUndrainedOutbox((offset, count) => page.all(count, offset), Math.min(
|
|
5853
|
+
const read = readUndrainedOutbox((offset, count) => page.all(count, offset), Math.min(assertRowLimit('limit', limit ?? 200), 1000));
|
|
5790
5854
|
this.recordAccess(actor, 'readUndrainedEvents', { tenantId, scopeId }, { limit }, read.events.length);
|
|
5791
5855
|
return undrainedEventsOf(read);
|
|
5792
5856
|
},
|
|
@@ -6305,8 +6369,10 @@ export class SqliteScopeHost {
|
|
|
6305
6369
|
const known = new Set(db.prepare(`SELECT name FROM sqlite_master WHERE type = 'table'`).all().map((r) => r.name));
|
|
6306
6370
|
if (!known.has(input.table))
|
|
6307
6371
|
throw new Error(`unknown table '${input.table}'`);
|
|
6308
|
-
|
|
6309
|
-
|
|
6372
|
+
// The ceiling clamps; a bound SQLite would misread (NaN, non-finite, fractional,
|
|
6373
|
+
// negative) is refused instead of reaching LIMIT / OFFSET (#1632).
|
|
6374
|
+
const limit = Math.min(assertRowLimit('limit', input.limit ?? SCOPE_TABLE_PAGE_DEFAULT), SCOPE_TABLE_PAGE_MAX);
|
|
6375
|
+
const offset = assertRowOffset('offset', input.offset ?? 0);
|
|
6310
6376
|
const stmt = db.prepare(`SELECT * FROM "${input.table}" LIMIT ? OFFSET ?`).raw(true);
|
|
6311
6377
|
const rows = stmt.all(limit, offset).map((row) => row.map(cellToJson));
|
|
6312
6378
|
const columns = stmt.columns().map((c) => c.name);
|
|
@@ -6375,6 +6441,15 @@ export class SqliteScopeHost {
|
|
|
6375
6441
|
drained: Number(w.drained ?? 0),
|
|
6376
6442
|
};
|
|
6377
6443
|
},
|
|
6444
|
+
// #1745: the refusal log, read like the denial log above — same K-3 cross-check,
|
|
6445
|
+
// same K-24 access row.
|
|
6446
|
+
listRefusals: async (actor, tenantId, scopeId, filter) => {
|
|
6447
|
+
const db = this.scopeReadDbFor(tenantId, scopeId);
|
|
6448
|
+
const q = refusalListQuery(filter);
|
|
6449
|
+
const rows = db.prepare(q.sql).all(...q.params).map(mapRefusalRow);
|
|
6450
|
+
this.recordAccess(actor, 'listRefusals', { tenantId, scopeId }, filter ?? null, rows.length);
|
|
6451
|
+
return rows;
|
|
6452
|
+
},
|
|
6378
6453
|
exportScope: async (actor, tenantId, scopeId) => {
|
|
6379
6454
|
// K-3: cross-check the pair before opening anything (same as the introspection reads).
|
|
6380
6455
|
const db = this.scopeReadDbFor(tenantId, scopeId);
|
|
@@ -6580,7 +6655,8 @@ export class SqliteScopeHost {
|
|
|
6580
6655
|
// Both scope-side redactions in ONE turn on the scope actor (#1678): issued while an
|
|
6581
6656
|
// invoke held its transaction open, they joined it, and its rollback put the
|
|
6582
6657
|
// person's PII back after this verb had destroyed the key and receipted the erasure.
|
|
6583
|
-
const
|
|
6658
|
+
const scopeSql = redactionSqlOf(db);
|
|
6659
|
+
const { redacted, intentsRedacted, jobRunsRedacted, text } = await this.runtime(tenantId, scopeId).actor.turn(() => ({
|
|
6584
6660
|
redacted: db
|
|
6585
6661
|
.prepare(`UPDATE _substrat_outbox SET payload = NULL
|
|
6586
6662
|
WHERE subject_id = ? AND pii_class != 'none' AND payload IS NOT NULL`)
|
|
@@ -6599,14 +6675,16 @@ export class SqliteScopeHost {
|
|
|
6599
6675
|
.run(REDACTED_DELIVERY_NOTE, REDACTED_DELIVERY_NOTE, subjectId),
|
|
6600
6676
|
// The job-run tables (#1632): a run, its cursor or a step's memo can hold a copy
|
|
6601
6677
|
// of the same event. The kernel's walk, so the DO runs the identical SQL.
|
|
6602
|
-
jobRunsRedacted: redactSubjectJobRuns(
|
|
6603
|
-
|
|
6604
|
-
|
|
6605
|
-
|
|
6606
|
-
stmt.run(...params);
|
|
6607
|
-
return [];
|
|
6608
|
-
}, subjectId, at),
|
|
6678
|
+
jobRunsRedacted: redactSubjectJobRuns(scopeSql, subjectId, at),
|
|
6679
|
+
// The free-text copies (#1632), and the tombstoned intents the directory half
|
|
6680
|
+
// follows. Last, so those ids include every intent tombstoned above.
|
|
6681
|
+
text: redactSubjectScopeText(scopeSql, subjectId, at),
|
|
6609
6682
|
}));
|
|
6683
|
+
const { idempotencyResults, intentIds } = text;
|
|
6684
|
+
// The directory's failure text (#1632) — a drain failure quoting one of those
|
|
6685
|
+
// intents, an issue's exemplar, a sweep record's error. Before the key, for the
|
|
6686
|
+
// ordering reason above.
|
|
6687
|
+
redactSubjectDirectoryText(redactionSqlOf(this.directory), { tenantId, scopeId, subjectId, intentIds });
|
|
6610
6688
|
const { existed } = await this.subjectKeysFor(tenantId, scopeId).destroy(subjectId, at);
|
|
6611
6689
|
const receipt = subjectShredReceipt.parse({
|
|
6612
6690
|
subjectId,
|
|
@@ -6622,7 +6700,7 @@ export class SqliteScopeHost {
|
|
|
6622
6700
|
this.recordAdmin(actor, 'shredSubject', { tenantId, scopeId }, null, receipt);
|
|
6623
6701
|
// BOTH counts: the access log's number is "how much evidence this destroyed", and
|
|
6624
6702
|
// an intent payload is a whole event's worth of it.
|
|
6625
|
-
this.recordAccess(actor, 'shredSubject', { tenantId, scopeId }, { subjectId }, redacted.changes + intentsRedacted + jobRunsRedacted);
|
|
6703
|
+
this.recordAccess(actor, 'shredSubject', { tenantId, scopeId }, { subjectId }, redacted.changes + intentsRedacted + jobRunsRedacted + idempotencyResults);
|
|
6626
6704
|
return receipt;
|
|
6627
6705
|
},
|
|
6628
6706
|
// -- impersonation (K-42, #868) ----------------------------------------
|
|
@@ -7248,6 +7326,7 @@ export class SqliteScopeHost {
|
|
|
7248
7326
|
return info.changes;
|
|
7249
7327
|
},
|
|
7250
7328
|
pruneAccessLog: async (actor, limit) => {
|
|
7329
|
+
assertRowLimit('limit', limit);
|
|
7251
7330
|
// ONLY drained rows. Age alone is not a licence to delete evidence.
|
|
7252
7331
|
const info = this.directory
|
|
7253
7332
|
.prepare(`DELETE FROM _substrat_access_log WHERE id IN (
|
|
@@ -7305,7 +7384,7 @@ export class SqliteScopeHost {
|
|
|
7305
7384
|
` ORDER BY id ${order}`;
|
|
7306
7385
|
if (filter?.limit !== undefined) {
|
|
7307
7386
|
sql += ' LIMIT ?';
|
|
7308
|
-
params.push(filter.limit);
|
|
7387
|
+
params.push(assertRowLimit('limit', filter.limit));
|
|
7309
7388
|
}
|
|
7310
7389
|
const rows = this.directory.prepare(sql).all(...params);
|
|
7311
7390
|
// Reading the audit trail is itself audited. Who examined the record of
|
|
@@ -7347,17 +7426,19 @@ export class SqliteScopeHost {
|
|
|
7347
7426
|
// resolved issue; an ignored one stays ignored — that is what ignoring means.
|
|
7348
7427
|
this.directory
|
|
7349
7428
|
.prepare(`INSERT INTO _substrat_issues
|
|
7350
|
-
(fingerprint, operation, stage, origin, code, status, seen_count, first_seen, last_seen, last_message, last_vertical, last_version, resolved_version, resolved_at)
|
|
7351
|
-
VALUES (?, ?, ?, ?, ?, 'new', 1, ?, ?, ?, ?, ?, NULL, NULL)
|
|
7429
|
+
(fingerprint, operation, stage, origin, code, status, seen_count, first_seen, last_seen, last_message, last_tenant_id, last_owner_kind, last_vertical, last_version, resolved_version, resolved_at)
|
|
7430
|
+
VALUES (?, ?, ?, ?, ?, 'new', 1, ?, ?, ?, ?, ?, ?, ?, NULL, NULL)
|
|
7352
7431
|
ON CONFLICT (fingerprint) DO UPDATE SET
|
|
7353
7432
|
seen_count = seen_count + 1,
|
|
7354
7433
|
last_seen = excluded.last_seen,
|
|
7355
7434
|
last_message = excluded.last_message,
|
|
7435
|
+
last_tenant_id = excluded.last_tenant_id,
|
|
7436
|
+
last_owner_kind = excluded.last_owner_kind,
|
|
7356
7437
|
last_vertical = COALESCE(excluded.last_vertical, last_vertical),
|
|
7357
7438
|
last_version = COALESCE(excluded.last_version, last_version),
|
|
7358
7439
|
origin = COALESCE(excluded.origin, origin),
|
|
7359
7440
|
status = CASE WHEN status = 'resolved' THEN 'regressed' ELSE status END`)
|
|
7360
|
-
.run(fingerprint, entry.operation, entry.stage ?? null, entry.origin ?? null, entry.code ?? null, at, at, entry.message.slice(0, 2000), entry.vertical ?? null, entry.version ?? null);
|
|
7441
|
+
.run(fingerprint, entry.operation, entry.stage ?? null, entry.origin ?? null, entry.code ?? null, at, at, entry.message.slice(0, 2000), entry.tenantId ?? null, issueExemplarOwner(entry.tenantId ?? null), entry.vertical ?? null, entry.version ?? null);
|
|
7361
7442
|
const issueHorizon = new Date(Date.now() - ISSUE_RETENTION_DAYS * 86_400_000).toISOString();
|
|
7362
7443
|
this.directory.prepare('DELETE FROM _substrat_issues WHERE last_seen < ?').run(issueHorizon);
|
|
7363
7444
|
},
|
|
@@ -7416,7 +7497,7 @@ export class SqliteScopeHost {
|
|
|
7416
7497
|
` ORDER BY id ${order}`;
|
|
7417
7498
|
if (filter?.limit !== undefined) {
|
|
7418
7499
|
sql += ' LIMIT ?';
|
|
7419
|
-
params.push(filter.limit);
|
|
7500
|
+
params.push(assertRowLimit('limit', filter.limit));
|
|
7420
7501
|
}
|
|
7421
7502
|
const rows = this.directory.prepare(sql).all(...params);
|
|
7422
7503
|
// Rows can name tenants and scopes, so reading them is recorded like the
|
|
@@ -7490,7 +7571,7 @@ export class SqliteScopeHost {
|
|
|
7490
7571
|
` ORDER BY id ${order}`;
|
|
7491
7572
|
if (filter?.limit !== undefined) {
|
|
7492
7573
|
sql += ' LIMIT ?';
|
|
7493
|
-
params.push(filter.limit);
|
|
7574
|
+
params.push(assertRowLimit('limit', filter.limit));
|
|
7494
7575
|
}
|
|
7495
7576
|
const rows = this.directory.prepare(sql).all(...params);
|
|
7496
7577
|
// Rows can name tenants and scopes, so reading them is recorded (K-24).
|
|
@@ -7514,6 +7595,15 @@ export class SqliteScopeHost {
|
|
|
7514
7595
|
at: r.at,
|
|
7515
7596
|
}));
|
|
7516
7597
|
},
|
|
7598
|
+
pruneTelemetry: async (_actor, limit) => {
|
|
7599
|
+
// A negative LIMIT is no limit at all to SQLite (#1632): refused before any statement.
|
|
7600
|
+
assertRowLimit('limit', limit);
|
|
7601
|
+
const pruned = { opsFailures: 0, issues: 0, sweepRuns: 0 };
|
|
7602
|
+
for (const { table, sql, params } of telemetryRetentionStatements(Date.now(), limit)) {
|
|
7603
|
+
pruned[table] = this.directory.prepare(sql).all(...params).length;
|
|
7604
|
+
}
|
|
7605
|
+
return pruned;
|
|
7606
|
+
},
|
|
7517
7607
|
listIssues: async (actor, filter) => {
|
|
7518
7608
|
const where = [];
|
|
7519
7609
|
const params = [];
|
|
@@ -7534,7 +7624,7 @@ export class SqliteScopeHost {
|
|
|
7534
7624
|
const sql = 'SELECT * FROM _substrat_issues' +
|
|
7535
7625
|
(where.length ? ` WHERE ${where.join(' AND ')}` : '') +
|
|
7536
7626
|
' ORDER BY last_seen DESC, fingerprint LIMIT ?';
|
|
7537
|
-
params.push(filter?.limit ?? 100);
|
|
7627
|
+
params.push(assertRowLimit('limit', filter?.limit ?? 100));
|
|
7538
7628
|
const rows = this.directory.prepare(sql).all(...params);
|
|
7539
7629
|
// Issues aggregate fleet-wide failures; the read is recorded like the rows' own (K-24).
|
|
7540
7630
|
this.recordAccess(actor, 'listIssues', {}, filter, rows.length);
|
|
@@ -7603,8 +7693,10 @@ export class SqliteScopeHost {
|
|
|
7603
7693
|
// Without case, as SQLite resolves a column name: a restore may have added this column
|
|
7604
7694
|
// already, untyped and spelled as a newer kernel's dump spelled it (#1883).
|
|
7605
7695
|
const existing = db.prepare(`PRAGMA table_info(${table})`).all().some((c) => c.name.toLowerCase() === column.toLowerCase());
|
|
7606
|
-
if (
|
|
7607
|
-
|
|
7696
|
+
if (existing)
|
|
7697
|
+
return false;
|
|
7698
|
+
db.exec(`ALTER TABLE ${table} ADD COLUMN ${ddl}`);
|
|
7699
|
+
return true;
|
|
7608
7700
|
}
|
|
7609
7701
|
/**
|
|
7610
7702
|
* #1288: `_substrat_schedule_state`, rebuilt with `kind` in its key on a scope db
|
|
@@ -7810,6 +7902,15 @@ export class SqliteScopeHost {
|
|
|
7810
7902
|
// #1236: the regression's version pair, on a directory whose issues table predates it.
|
|
7811
7903
|
this.ensureColumn(this.directory, '_substrat_issues', 'last_version', 'last_version TEXT');
|
|
7812
7904
|
this.ensureColumn(this.directory, '_substrat_issues', 'resolved_version', 'resolved_version TEXT');
|
|
7905
|
+
// #1632: whose exemplar `last_message` is. Backfilled once, as the columns arrive, from
|
|
7906
|
+
// the retained ops-failure rows that prove it; every other row stays unknown. The columns
|
|
7907
|
+
// and the backfill commit together because this whole pass is `ensureDirectorySchema`'s
|
|
7908
|
+
// one transaction: the backfill's gate is "this call added the column", so a column
|
|
7909
|
+
// committed ahead of a backfill that then failed would read as migrated for good.
|
|
7910
|
+
this.ensureColumn(this.directory, '_substrat_issues', 'last_tenant_id', 'last_tenant_id TEXT');
|
|
7911
|
+
if (this.ensureColumn(this.directory, '_substrat_issues', 'last_owner_kind', 'last_owner_kind TEXT')) {
|
|
7912
|
+
this.directory.exec(ISSUE_EXEMPLAR_OWNER_BACKFILL_SQL);
|
|
7913
|
+
}
|
|
7813
7914
|
this.directory.exec('CREATE INDEX IF NOT EXISTS _substrat_ops_failures_fingerprint ON _substrat_ops_failures (fingerprint, id)');
|
|
7814
7915
|
// #1232: the drained batch's dedupe key, on a directory created before it. The
|
|
7815
7916
|
// unique index rides here too — created after the column exists on every path.
|
|
@@ -8619,6 +8720,16 @@ function cellToJson(v) {
|
|
|
8619
8720
|
return null;
|
|
8620
8721
|
return v;
|
|
8621
8722
|
}
|
|
8723
|
+
/** The kernel's erasure walks over one SQLite connection — a read returns its rows, a write none. */
|
|
8724
|
+
function redactionSqlOf(db) {
|
|
8725
|
+
return (sql, params) => {
|
|
8726
|
+
const stmt = db.prepare(sql);
|
|
8727
|
+
if (stmt.reader)
|
|
8728
|
+
return stmt.all(...params);
|
|
8729
|
+
stmt.run(...params);
|
|
8730
|
+
return [];
|
|
8731
|
+
};
|
|
8732
|
+
}
|
|
8622
8733
|
/**
|
|
8623
8734
|
* The connection module code holds. `guardSpine` is what makes "never write
|
|
8624
8735
|
* `_substrat_*`" a mechanism rather than a lint rule (#954) — the kernel's own
|