@@ -6,16 +6,19 @@ import { resolve } from "node:path";
66import postgres from "../../apps/cloud/node_modules/postgres/src/index.js" ;
77
88import {
9+ operationStorageKey ,
910 planMigration ,
1011 renderOrgDiff ,
1112 renderSummary ,
13+ storageDataRecord ,
1214 tenantHash ,
1315 verifyPolicyRewriteNeverWidens ,
16+ type BlobRow ,
1417 type ConnectionRow ,
15- type DefinitionRow ,
1618 type IntegrationRow ,
1719 type MigrationInput ,
1820 type OrgPlan ,
21+ type PluginStorageRow ,
1922 type ToolPolicyRow ,
2023 type ToolRow ,
2124} from "./oneshot-service-split" ;
@@ -73,7 +76,11 @@ const readCompletedTenants = async (
7376 return rows . map ( ( row ) => row . tenant ) ;
7477} ;
7578
76- const readDatabaseInput = async ( sql : SqlClient , dryRun : boolean ) : Promise < MigrationInput > => {
79+ const readDatabaseInput = async (
80+ sql : SqlClient ,
81+ dryRun : boolean ,
82+ blobBackend : MigrationInput [ "blobBackend" ] ,
83+ ) : Promise < MigrationInput > => {
7784 const completedTenants = await readCompletedTenants ( sql , dryRun ) ;
7885 const integrations = await readRows < IntegrationRow [ ] > (
7986 sql ,
@@ -118,21 +125,40 @@ const readDatabaseInput = async (sql: SqlClient, dryRun: boolean): Promise<Migra
118125 WHERE (plugin_id = 'google' AND slug = 'google')
119126 OR (plugin_id = 'microsoft' AND slug = 'microsoft')
120127 )
128+ AND integration IN ('google', 'microsoft')
121129 ORDER BY tenant, integration, connection, name
122130 ` ,
123131 ) ;
124- const definitions = await readRows < DefinitionRow [ ] > (
125- sql ,
132+ const pluginStorage = await sql . unsafe < PluginStorageRow [ ] > (
126133 `
127- SELECT tenant, owner, subject, integration, connection, plugin_id, name, schema ,
128- created_at::text, row_id
129- FROM definition
134+ SELECT tenant, owner, subject, plugin_id, collection, key, data ,
135+ created_at::text, updated_at::text, row_id
136+ FROM plugin_storage
130137 WHERE tenant IN (
131138 SELECT tenant FROM integration
132139 WHERE (plugin_id = 'google' AND slug = 'google')
133140 OR (plugin_id = 'microsoft' AND slug = 'microsoft')
141+ )
142+ AND plugin_id IN ('google', 'microsoft')
143+ AND collection = 'operation'
144+ AND (key LIKE $1 OR key LIKE $2 OR key LIKE 'google.%' OR key LIKE 'microsoft.%')
145+ ORDER BY tenant, plugin_id, collection, key
146+ ` ,
147+ [ operationKeyPrefix ( "google" ) , operationKeyPrefix ( "microsoft" ) ] ,
148+ ) ;
149+ const blobs = await readRows < BlobRow [ ] > (
150+ sql ,
151+ `
152+ SELECT id, namespace, key
153+ FROM blob
154+ WHERE namespace IN (
155+ SELECT 'o:' || tenant || '/' || plugin_id
156+ FROM integration
157+ WHERE (plugin_id = 'google' AND slug = 'google')
158+ OR (plugin_id = 'microsoft' AND slug = 'microsoft')
134159 )
135- ORDER BY tenant, integration, connection, name
160+ AND (key LIKE 'spec/%' OR key LIKE 'defs/%')
161+ ORDER BY namespace, key
136162 ` ,
137163 ) ;
138164 const policies = await readRows < ToolPolicyRow [ ] > (
@@ -153,10 +179,12 @@ const readDatabaseInput = async (sql: SqlClient, dryRun: boolean): Promise<Migra
153179 integrations,
154180 connections,
155181 tools,
156- definitions,
182+ pluginStorage,
183+ blobs,
157184 policies,
158185 completedTenants,
159186 trafficLastTenant,
187+ blobBackend,
160188 } ;
161189} ;
162190
@@ -181,56 +209,8 @@ const stableId = (...parts: readonly string[]): string => {
181209 return `oneshot_${ hash . toString ( 36 ) } ` ;
182210} ;
183211
184- const serviceToolSql = ( presetId : string ) : string => {
185- const prefixesByPreset : Readonly < Record < string , readonly string [ ] > > = {
186- "google-calendar" : [ "calendar." ] ,
187- "google-gmail" : [ "gmail." ] ,
188- "google-sheets" : [ "sheets." ] ,
189- "google-drive" : [ "drive." ] ,
190- "google-docs" : [ "docs." ] ,
191- "google-slides" : [ "slides." ] ,
192- "google-forms" : [ "forms." ] ,
193- "google-tasks" : [ "tasks." ] ,
194- "google-people" : [ "people." ] ,
195- "google-photos-library" : [ "photoslibrary." ] ,
196- "google-photos-picker" : [ "photospicker." ] ,
197- "google-chat" : [ "chat." ] ,
198- "google-keep" : [ "keep." ] ,
199- "google-youtube-data" : [ "youtube." ] ,
200- "google-search-console" : [ "searchconsole." , "webmasters." ] ,
201- "google-classroom" : [ "classroom." ] ,
202- "google-admin-directory" : [ "admin." ] ,
203- "google-admin-reports" : [ "admin." ] ,
204- "google-apps-script" : [ "script." ] ,
205- "google-bigquery" : [ "bigquery." ] ,
206- "google-cloud-resource-manager" : [ "cloudresourcemanager." ] ,
207- profile : [ "meUser." , "usersUser." , "meProfilePhoto." ] ,
208- mail : [
209- "meMessage." ,
210- "usersMessage." ,
211- "meMail" ,
212- "usersMail" ,
213- "meMailbox" ,
214- "meOutlook" ,
215- "usersOutlook" ,
216- ] ,
217- calendar : [ "meCalendar" , "usersCalendar" , "meEvent." , "usersEvent." ] ,
218- contacts : [ "meContact" , "usersContact" , "mePerson." , "usersPerson." ] ,
219- tasks : [ "meTodo." , "usersTodo." ] ,
220- planner : [ "planner." , "mePlanner." , "usersPlanner." ] ,
221- files : [ "drives" , "meDrive" , "usersDrive" , "groupsDrive" , "shares" ] ,
222- excel : [ "workbook" , "Workbook" ] ,
223- sites : [ "sites" ] ,
224- onenote : [ "meOnenote" , "usersOnenote" , "groupsOnenote" , "sitesOnenote" ] ,
225- "teams-chat" : [ "chats" , "meChat" ] ,
226- "teams-channels" : [ "teams" , "teamwork" , "meTeam" , "groupsTeam" ] ,
227- "meetings-calls" : [ "communications" , "meOnlineMeeting" , "usersOnlineMeeting" ] ,
228- } ;
229- const prefixes = prefixesByPreset [ presetId ] ?? [ ] ;
230- const clauses = prefixes . map ( ( prefix ) => `name LIKE '${ prefix . replaceAll ( "'" , "''" ) } %'` ) ;
231- if ( presetId . startsWith ( "google-" ) ) clauses . push ( "name LIKE 'oauth2.%'" ) ;
232- return clauses . length > 0 ? `(${ clauses . join ( " OR " ) } )` : "false" ;
233- } ;
212+ const operationKeyPrefix = ( integration : string ) : string =>
213+ `${ operationStorageKey ( integration , "" ) . split ( "." ) . slice ( 0 , 2 ) . join ( "." ) } .%` ;
234214
235215const applyOrg = async ( sql : SqlClient , org : OrgPlan ) : Promise < void > => {
236216 if ( org . completed ) return ;
@@ -296,45 +276,92 @@ const applyOrg = async (sql: SqlClient, org: OrgPlan): Promise<void> => {
296276 }
297277
298278 for ( const integration of org . integrations ) {
299- const whereTools = serviceToolSql ( integration . target . presetId ) ;
300- await tx . unsafe (
301- `
302- INSERT INTO tool (
303- integration, connection, plugin_id, name, description, input_schema, output_schema,
304- annotations, created_at, updated_at, row_id, tenant, owner, subject
305- )
306- SELECT $1, connection, plugin_id, name, description, input_schema, output_schema,
307- annotations, created_at, $2, concat($3::text, '_', row_id), tenant, owner, subject
308- FROM tool
309- WHERE tenant = $4 AND integration = $5 AND ${ whereTools }
310- ON CONFLICT (tenant, owner, subject, integration, connection, name) DO NOTHING
311- ` ,
312- [
313- integration . target . slug ,
314- now ,
315- stableId ( "tool" , org . tenant , integration . target . slug ) ,
316- org . tenant ,
317- integration . source . slug ,
318- ] ,
319- ) ;
320- await tx . unsafe (
321- `
322- INSERT INTO definition (
323- integration, connection, plugin_id, name, schema, created_at, row_id, tenant, owner, subject
324- )
325- SELECT $1, connection, plugin_id, name, schema, created_at, concat($2::text, '_', row_id),
326- tenant, owner, subject
327- FROM definition
328- WHERE tenant = $3 AND integration = $4 AND ${ whereTools }
329- ON CONFLICT (tenant, owner, subject, integration, connection, name) DO NOTHING
330- ` ,
331- [
332- integration . target . slug ,
333- stableId ( "definition" , org . tenant , integration . target . slug ) ,
334- org . tenant ,
335- integration . source . slug ,
336- ] ,
337- ) ;
279+ for ( const toolName of integration . servingState . operationToolNames ) {
280+ const [ operation ] = await tx . unsafe <
281+ {
282+ readonly data : unknown ;
283+ readonly created_at : string ;
284+ readonly updated_at : string ;
285+ } [ ]
286+ > (
287+ `
288+ SELECT data, created_at::text, updated_at::text
289+ FROM plugin_storage
290+ WHERE tenant = $1
291+ AND owner = 'org'
292+ AND subject = ''
293+ AND plugin_id = $2
294+ AND collection = 'operation'
295+ AND (
296+ key = $3
297+ OR (
298+ data::jsonb ->> 'integration' = $4
299+ AND data::jsonb ->> 'toolName' = $5
300+ )
301+ )
302+ LIMIT 1
303+ ` ,
304+ [
305+ org . tenant ,
306+ integration . source . plugin_id ,
307+ operationStorageKey ( integration . source . slug , toolName ) ,
308+ integration . source . slug ,
309+ toolName ,
310+ ] ,
311+ ) ;
312+ if ( ! operation ) {
313+ throw new Error (
314+ `Missing operation row for ${ tenantHash ( org . tenant ) } /${ integration . source . slug } /${ toolName } ` ,
315+ ) ;
316+ }
317+ await tx . unsafe (
318+ `
319+ INSERT INTO plugin_storage (
320+ plugin_id, collection, key, data, created_at, updated_at, row_id,
321+ tenant, owner, subject
322+ )
323+ VALUES ($1, 'operation', $2, $3::json, $4, $5, $6, $7, 'org', '')
324+ ON CONFLICT (tenant, owner, subject, plugin_id, collection, key)
325+ DO UPDATE SET data = EXCLUDED.data, updated_at = EXCLUDED.updated_at
326+ ` ,
327+ [
328+ integration . source . plugin_id ,
329+ operationStorageKey ( integration . target . slug , toolName ) ,
330+ JSON . stringify (
331+ scrubJson ( {
332+ ...storageDataRecord ( operation ) ,
333+ integration : integration . target . slug ,
334+ toolName,
335+ } ) ,
336+ ) ,
337+ operation . created_at ,
338+ now ,
339+ stableId ( "operation" , org . tenant , integration . target . slug , toolName ) ,
340+ org . tenant ,
341+ ] ,
342+ ) ;
343+ await tx . unsafe (
344+ `
345+ INSERT INTO tool (
346+ integration, connection, plugin_id, name, description, input_schema, output_schema,
347+ annotations, created_at, updated_at, row_id, tenant, owner, subject
348+ )
349+ SELECT $1, connection, plugin_id, name, description, input_schema, output_schema,
350+ annotations, created_at, $2, concat($3::text, '_', row_id), tenant, owner, subject
351+ FROM tool
352+ WHERE tenant = $4 AND integration = $5 AND name = $6
353+ ON CONFLICT (tenant, owner, subject, integration, connection, name) DO NOTHING
354+ ` ,
355+ [
356+ integration . target . slug ,
357+ now ,
358+ stableId ( "tool" , org . tenant , integration . target . slug ) ,
359+ org . tenant ,
360+ integration . source . slug ,
361+ toolName ,
362+ ] ,
363+ ) ;
364+ }
338365 }
339366
340367 for ( const policy of org . policies . filter ( ( item ) => item . action === "rewrite" ) ) {
@@ -369,10 +396,6 @@ const applyOrg = async (sql: SqlClient, org: OrgPlan): Promise<void> => {
369396 }
370397
371398 for ( const monolith of org . deleteMonoliths ) {
372- await tx . unsafe ( "DELETE FROM definition WHERE tenant = $1 AND integration = $2" , [
373- org . tenant ,
374- monolith . slug ,
375- ] ) ;
376399 await tx . unsafe ( "DELETE FROM tool WHERE tenant = $1 AND integration = $2" , [
377400 org . tenant ,
378401 monolith . slug ,
@@ -399,7 +422,7 @@ const applyOrg = async (sql: SqlClient, org: OrgPlan): Promise<void> => {
399422} ;
400423
401424const writeDryRun = ( planInput : MigrationInput ) : void => {
402- const plan = planMigration ( planInput ) ;
425+ const plan = planMigration ( { ... planInput , collectPolicyErrors : true } ) ;
403426 const neverWiden = verifyPolicyRewriteNeverWidens ( plan , planInput ) ;
404427 mkdirSync ( outputDir , { recursive : true } ) ;
405428 for ( const org of plan . orgs ) {
@@ -414,7 +437,26 @@ const writeDryRun = (planInput: MigrationInput): void => {
414437 console . log ( `policy_never_widen_checked=${ neverWiden . checkedPolicies } ` ) ;
415438 console . log ( `diff_dir=${ outputDir } ` ) ;
416439 if ( ! neverWiden . ok ) {
417- throw new Error ( `Policy rewrite widened ${ neverWiden . widened . length } policy row(s)` ) ;
440+ throw new Error (
441+ `Policy rewrite failed coverage checks: widened=${ neverWiden . widened . length } , narrowed=${ neverWiden . narrowed . length } ` ,
442+ ) ;
443+ }
444+ if (
445+ plan . summary . integrationsMissingSpecBlob > 0 ||
446+ plan . summary . integrationsMissingDefsBlob > 0
447+ ) {
448+ throw new Error (
449+ `Serving blob check failed: missing spec=${ plan . summary . integrationsMissingSpecBlob } , missing defs=${ plan . summary . integrationsMissingDefsBlob } ` ,
450+ ) ;
451+ }
452+ const zeroOperationIntegrations = plan . orgs
453+ . filter ( ( org ) => ! org . completed && org . hardErrors . length === 0 )
454+ . flatMap ( ( org ) => org . integrations )
455+ . filter ( ( integration ) => integration . servingState . operationsToBuild === 0 ) ;
456+ if ( zeroOperationIntegrations . length > 0 ) {
457+ throw new Error (
458+ `Serving operation check failed for ${ zeroOperationIntegrations . length } planned integration(s)` ,
459+ ) ;
418460 }
419461} ;
420462
@@ -434,15 +476,34 @@ const main = async (): Promise<void> => {
434476 ...( usesLocalDatabase ? { } : { ssl : "require" as const } ) ,
435477 } ) as unknown as SqlClient ;
436478 try {
437- const input = await readDatabaseInput ( sql , ! apply ) ;
438- const plan = planMigration ( input ) ;
479+ const input = await readDatabaseInput ( sql , ! apply , usesLocalDatabase ? "database" : "external" ) ;
439480 if ( ! apply ) {
440481 writeDryRun ( input ) ;
441482 return ;
442483 }
484+ const plan = planMigration ( input ) ;
443485 const neverWiden = verifyPolicyRewriteNeverWidens ( plan , input ) ;
444486 if ( ! neverWiden . ok ) {
445- throw new Error ( `Policy rewrite widened ${ neverWiden . widened . length } policy row(s)` ) ;
487+ throw new Error (
488+ `Policy rewrite failed coverage checks: widened=${ neverWiden . widened . length } , narrowed=${ neverWiden . narrowed . length } ` ,
489+ ) ;
490+ }
491+ if (
492+ plan . summary . integrationsMissingSpecBlob > 0 ||
493+ plan . summary . integrationsMissingDefsBlob > 0
494+ ) {
495+ throw new Error (
496+ `Serving blob check failed: missing spec=${ plan . summary . integrationsMissingSpecBlob } , missing defs=${ plan . summary . integrationsMissingDefsBlob } ` ,
497+ ) ;
498+ }
499+ const zeroOperationIntegrations = plan . orgs
500+ . filter ( ( org ) => ! org . completed && org . hardErrors . length === 0 )
501+ . flatMap ( ( org ) => org . integrations )
502+ . filter ( ( integration ) => integration . servingState . operationsToBuild === 0 ) ;
503+ if ( zeroOperationIntegrations . length > 0 ) {
504+ throw new Error (
505+ `Serving operation check failed for ${ zeroOperationIntegrations . length } planned integration(s)` ,
506+ ) ;
446507 }
447508 for ( const org of plan . orgs ) {
448509 await applyOrg ( sql , org ) ;
0 commit comments