3636 */
3737
3838import {
39+ SYSTEM_CTX ,
3940 genId ,
4041 permissionSetRowFields ,
41- tryFind ,
42+ recordDiffersFromBody ,
4243 tryInsert ,
4344 tryUpdate ,
4445 type PermissionSeedOutcome ,
4546 type ProjectionLogger ,
4647} from './permission-set-projection.js' ;
48+ import {
49+ buildExistingByName ,
50+ type ExistingByNameIndex ,
51+ type ExistingLookupResult ,
52+ } from './seed-name-lookup.js' ;
4753
4854export type { PermissionSeedOutcome } from './permission-set-projection.js' ;
4955
56+ /**
57+ * The per-name existence read used when no batched oracle was supplied (the
58+ * ADR-0086 P2 publish materializer, which upserts exactly one set). Reports the
59+ * same three outcomes the batched oracle does — a failed read must not read as
60+ * "absent" on this path either.
61+ */
62+ async function defaultLookup ( ql : any , name : string ) : Promise < ExistingLookupResult > {
63+ let rows : any ;
64+ try {
65+ rows = await ql . find ( 'sys_permission_set' , { where : { name } , limit : 1 } , { context : SYSTEM_CTX } ) ;
66+ } catch {
67+ return { status : 'unknown' } ;
68+ }
69+ const list = Array . isArray ( rows ) ? rows : Array . isArray ( rows ?. records ) ? rows . records : null ;
70+ if ( list === null ) return { status : 'unknown' } ;
71+ return list [ 0 ] ? { status : 'present' , row : list [ 0 ] } : { status : 'absent' } ;
72+ }
73+
5074interface SeedOptions {
5175 logger ?: ProjectionLogger ;
5276}
@@ -96,8 +120,17 @@ export async function upsertPackagePermissionSet(
96120 ps : any ,
97121 packageId : string | null | undefined ,
98122 logger ?: SeedOptions [ 'logger' ] ,
123+ opts ?: {
124+ /**
125+ * Existence oracle to consult instead of this function's own per-name
126+ * `SELECT` (#10946). The boot loop passes ONE batched read covering every
127+ * declared name; the publish materializer, which upserts a single set,
128+ * passes nothing and keeps the per-name read.
129+ */
130+ existingByName ?: ExistingByNameIndex ;
131+ } ,
99132) : Promise < PermissionSeedOutcome > {
100- const out : PermissionSeedOutcome = { seeded : 0 , updated : 0 , skippedEnvAuthored : 0 , skippedForeign : 0 } ;
133+ const out : PermissionSeedOutcome = { seeded : 0 , updated : 0 , unchanged : 0 , unreadable : 0 , skippedEnvAuthored : 0 , skippedForeign : 0 } ;
101134 if ( ! ps ?. name ) return out ;
102135 // A `managed_by:'package'` row without a `package_id` would make uninstall
103136 // undefined again — the exact ambiguity ADR-0086 D3 exists to remove — so a
@@ -107,25 +140,59 @@ export async function upsertPackagePermissionSet(
107140 return out ;
108141 }
109142
110- const existing = ( await tryFind ( ql , 'sys_permission_set' , { name : ps . name } , 1 ) ) [ 0 ] ;
143+ // ⛔ Three outcomes, not two (#10946 / #3807). `unknown` — the read FAILED —
144+ // is not "no such row": inserting on it would re-create a set that already
145+ // exists, and on a batched read one failure speaks for every declared name at
146+ // once. Declining is the answer; the caller's warn reports it.
147+ const lookup = opts ?. existingByName
148+ ? await opts . existingByName . get ( String ( ps . name ) )
149+ : await defaultLookup ( ql , String ( ps . name ) ) ;
150+ if ( lookup . status === 'unknown' ) {
151+ out . unreadable += 1 ;
152+ return out ;
153+ }
154+ const existing = lookup . status === 'present' ? lookup . row : undefined ;
111155 if ( ! existing ?. id ) {
112- const created = await tryInsert ( ql , 'sys_permission_set' , {
156+ const row = {
113157 id : genId ( 'ps' ) ,
114158 name : ps . name ,
115159 ...permissionSetRowFields ( ps ) ,
116160 active : true ,
117161 package_id : packageId ,
118162 managed_by : 'package' ,
119- } ) ;
120- if ( created ) out . seeded += 1 ;
163+ } ;
164+ const created = await tryInsert ( ql , 'sys_permission_set' , row ) ;
165+ if ( created ) {
166+ out . seeded += 1 ;
167+ // A batched oracle is a snapshot taken before the loop — tell it about
168+ // the row we just made, so a name declared twice in one batch still
169+ // reaches the collision branch below instead of a second insert.
170+ opts ?. existingByName ?. remember ( String ( ps . name ) , row ) ;
171+ }
121172 return out ;
122173 }
123174
124175 if ( existing . managed_by === 'package' ) {
125176 if ( existing . package_id === packageId ) {
126177 // Our own row — re-seed so the record always reflects the shipped/published
127178 // declaration (idempotent; covers version bumps without bookkeeping).
128- if ( await tryUpdate ( ql , 'sys_permission_set' , { id : existing . id , ...permissionSetRowFields ( ps ) } ) ) {
179+ //
180+ // [#10946] "Idempotent" was implemented as "write the same columns every
181+ // time", which on a remote libsql/Turso database is two HTTP round trips
182+ // per set on every boot to change nothing. `recordDiffersFromBody` is the
183+ // SAME comparison the ADR-0094 boot reconciler already trusts to decide
184+ // whether a record drifted, over exactly the columns
185+ // `permissionSetRowFields` writes — so a row it calls equal is a row this
186+ // UPDATE could not have changed.
187+ //
188+ // ⚠️ The skip is on EQUALITY, never on "we have seen this name". A row
189+ // whose stored value differs — a version bump, a hand-edit, a partially
190+ // applied write — still gets its UPDATE, because dropping that leg would
191+ // turn the loop into a no-op that reconciles nothing while showing a
192+ // beautiful round-trip curve.
193+ if ( ! recordDiffersFromBody ( existing , ps ) ) {
194+ out . unchanged += 1 ;
195+ } else if ( await tryUpdate ( ql , 'sys_permission_set' , { id : existing . id , ...permissionSetRowFields ( ps ) } ) ) {
129196 out . updated += 1 ;
130197 }
131198 } else {
@@ -151,7 +218,7 @@ export async function bootstrapDeclaredPermissions(
151218 metadataService : any ,
152219 options : SeedOptions = { } ,
153220) : Promise < PermissionSeedOutcome > {
154- const out : PermissionSeedOutcome = { seeded : 0 , updated : 0 , skippedEnvAuthored : 0 , skippedForeign : 0 } ;
221+ const out : PermissionSeedOutcome = { seeded : 0 , updated : 0 , unchanged : 0 , unreadable : 0 , skippedEnvAuthored : 0 , skippedForeign : 0 } ;
155222 if ( ! ql || typeof ql . find !== 'function' || typeof ql . insert !== 'function' ) return out ;
156223
157224 let sets : any [ ] = readDeclared ( ql , 'permission' ) ;
@@ -163,18 +230,40 @@ export async function bootstrapDeclaredPermissions(
163230 }
164231 if ( ! Array . isArray ( sets ) || sets . length === 0 ) return out ;
165232
233+ // [#10946] ONE existence read for the whole declaration, before the loop —
234+ // the set of names is known in full here. See `seed-name-lookup.ts` for why
235+ // a read that cannot ANSWER must not be read as "none of them exist".
236+ const existingByName = await buildExistingByName (
237+ ql ,
238+ 'sys_permission_set' ,
239+ sets . map ( ( ps ) => ps ?. name ) ,
240+ options . logger ,
241+ ) ;
242+
166243 for ( const ps of sets ) {
167244 if ( ! ps ?. name ) continue ;
168245 // Registry provenance first (ADR-0010 `_packageId`), author-declared
169246 // spec `packageId` (ADR-0086 D3) as fallback.
170247 const packageId : string | undefined = ps . _packageId ?? ps . packageId ?? undefined ;
171- const r = await upsertPackagePermissionSet ( ql , ps , packageId , options . logger ) ;
248+ const r = await upsertPackagePermissionSet ( ql , ps , packageId , options . logger , { existingByName } ) ;
172249 out . seeded += r . seeded ;
173250 out . updated += r . updated ;
251+ out . unchanged += r . unchanged ;
252+ out . unreadable += r . unreadable ;
174253 out . skippedEnvAuthored += r . skippedEnvAuthored ;
175254 out . skippedForeign += r . skippedForeign ;
176255 }
177256
257+ if ( out . unreadable > 0 ) {
258+ // Said once, with the count: these sets were neither seeded nor reconciled
259+ // because the record could not be READ. Silence here would read exactly
260+ // like "everything was already in order".
261+ options . logger ?. warn ?.(
262+ '[security] declared permission sets left untouched — their records could not be read' ,
263+ { unreadable : out . unreadable , total : sets . length } ,
264+ ) ;
265+ }
266+
178267 options . logger ?. info ?.( '[security] declared permission sets seeded into sys_permission_set (ADR-0086 D5)' , {
179268 ...out , total : sets . length ,
180269 } ) ;
0 commit comments