weaverstack 0.1.1__py3-none-any.whl

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.
Files changed (127) hide show
  1. weaver/__init__.py +59 -0
  2. weaver/build_bundle/__init__.py +109 -0
  3. weaver/build_bundle/aliases.py +325 -0
  4. weaver/build_bundle/bundle.py +359 -0
  5. weaver/build_bundle/catalogue_actions.py +275 -0
  6. weaver/build_bundle/changes.py +186 -0
  7. weaver/build_bundle/endpoints.py +83 -0
  8. weaver/build_bundle/executors/__init__.py +69 -0
  9. weaver/build_bundle/executors/alias.py +202 -0
  10. weaver/build_bundle/executors/base.py +132 -0
  11. weaver/build_bundle/executors/folder.py +71 -0
  12. weaver/build_bundle/executors/load_file.py +205 -0
  13. weaver/build_bundle/executors/spark_case.py +26 -0
  14. weaver/build_bundle/executors/spark_schema.py +60 -0
  15. weaver/build_bundle/executors/spark_sql.py +59 -0
  16. weaver/build_bundle/executors/spark_sql_batch.py +57 -0
  17. weaver/build_bundle/executors/spark_table.py +213 -0
  18. weaver/build_bundle/executors/sql_endpoint_refresh.py +34 -0
  19. weaver/build_bundle/executors/tsql.py +81 -0
  20. weaver/build_bundle/incremental.py +288 -0
  21. weaver/build_bundle/installer.py +384 -0
  22. weaver/build_bundle/models.py +288 -0
  23. weaver/build_bundle/payloads.py +34 -0
  24. weaver/build_bundle/physical.py +625 -0
  25. weaver/build_bundle/planner.py +389 -0
  26. weaver/build_bundle/prune.py +620 -0
  27. weaver/build_bundle/report.py +108 -0
  28. weaver/build_bundle/stages.py +196 -0
  29. weaver/build_bundle/targets.py +272 -0
  30. weaver/build_bundle/workflow.py +585 -0
  31. weaver/catalogue/__init__.py +73 -0
  32. weaver/catalogue/builtin.py +238 -0
  33. weaver/catalogue/claims.py +121 -0
  34. weaver/catalogue/projection.py +437 -0
  35. weaver/catalogue/reader.py +152 -0
  36. weaver/catalogue/reconcile.py +231 -0
  37. weaver/catalogue/render.py +410 -0
  38. weaver/catalogue/state.py +660 -0
  39. weaver/catalogue/tables.py +648 -0
  40. weaver/config.py +178 -0
  41. weaver/declaration/__init__.py +171 -0
  42. weaver/declaration/columns.py +223 -0
  43. weaver/declaration/ddl.py +266 -0
  44. weaver/declaration/dependencies.py +544 -0
  45. weaver/declaration/graph.py +240 -0
  46. weaver/declaration/item_dependencies.py +292 -0
  47. weaver/declaration/load.py +191 -0
  48. weaver/declaration/metadata.py +1405 -0
  49. weaver/declaration/model.py +448 -0
  50. weaver/declaration/references.py +294 -0
  51. weaver/declaration/repository.py +959 -0
  52. weaver/declaration/schemas.py +135 -0
  53. weaver/declaration/source.py +674 -0
  54. weaver/declaration/spark_load.py +759 -0
  55. weaver/declaration/sql_shaping.py +591 -0
  56. weaver/declaration/templates/ddl/declared_create_table.sql +64 -0
  57. weaver/declaration/templates/ddl/infer_create_table.sql +97 -0
  58. weaver/declaration/templates/ddl/metadata_column_validation.sql +30 -0
  59. weaver/declaration/templates/load/column_metadata.sql +40 -0
  60. weaver/declaration/templates/load/full_replace_body.sql +21 -0
  61. weaver/declaration/templates/load/install_load_procedure.sql +27 -0
  62. weaver/declaration/templates/load/load_procedure.sql +48 -0
  63. weaver/declaration/templates/load/primary_key_body.sql +113 -0
  64. weaver/declaration/tsql_ddl.py +468 -0
  65. weaver/declaration/tsql_load.py +417 -0
  66. weaver/declaration/warehouse_type_mapping.yml +93 -0
  67. weaver/diagnostics.py +247 -0
  68. weaver/errors.py +61 -0
  69. weaver/etl.py +469 -0
  70. weaver/fabric/__init__.py +107 -0
  71. weaver/fabric/auth.py +137 -0
  72. weaver/fabric/capacity.py +143 -0
  73. weaver/fabric/client.py +147 -0
  74. weaver/fabric/environment.py +460 -0
  75. weaver/fabric/livy.py +478 -0
  76. weaver/fabric/notebooks.py +201 -0
  77. weaver/fabric/onelake.py +263 -0
  78. weaver/fabric/resolution.py +344 -0
  79. weaver/fabric/resources.py +245 -0
  80. weaver/fabric/session.py +148 -0
  81. weaver/fabric/shortcuts.py +120 -0
  82. weaver/fabric/sql.py +118 -0
  83. weaver/fabric/store.py +198 -0
  84. weaver/initialise.py +209 -0
  85. weaver/lakehouse.py +386 -0
  86. weaver/load.py +474 -0
  87. weaver/load_execution.py +483 -0
  88. weaver/load_plan.py +912 -0
  89. weaver/load_report.py +330 -0
  90. weaver/load_resolution.py +386 -0
  91. weaver/locations.py +164 -0
  92. weaver/objects.py +392 -0
  93. weaver/operations.py +757 -0
  94. weaver/physical_wipe.py +369 -0
  95. weaver/push.py +76 -0
  96. weaver/resolution.py +292 -0
  97. weaver/runtime/__init__.py +30 -0
  98. weaver/runtime/folder_load.py +402 -0
  99. weaver/runtime/load_contract.py +245 -0
  100. weaver/runtime/load_result.py +104 -0
  101. weaver/runtime/spark_load.py +152 -0
  102. weaver/runtime/table_load.py +497 -0
  103. weaver/spark/__init__.py +49 -0
  104. weaver/spark/catalogue.py +245 -0
  105. weaver/spark/destination.py +195 -0
  106. weaver/spark/session.py +84 -0
  107. weaver/spark/tokens.py +138 -0
  108. weaver/sql/__init__.py +40 -0
  109. weaver/sql/authentication.py +38 -0
  110. weaver/sql/connection.py +90 -0
  111. weaver/sql/errors.py +25 -0
  112. weaver/sql/execution.py +123 -0
  113. weaver/sql/pool.py +174 -0
  114. weaver/sql/wipe.py +156 -0
  115. weaver/store.py +209 -0
  116. weaver/targets.py +257 -0
  117. weaver/task_logging.py +215 -0
  118. weaver/unbind.py +74 -0
  119. weaver/workspaces.py +175 -0
  120. weaver_cli/__init__.py +12 -0
  121. weaver_cli/__main__.py +7 -0
  122. weaver_cli/main.py +626 -0
  123. weaverstack-0.1.1.dist-info/METADATA +113 -0
  124. weaverstack-0.1.1.dist-info/RECORD +127 -0
  125. weaverstack-0.1.1.dist-info/WHEEL +4 -0
  126. weaverstack-0.1.1.dist-info/entry_points.txt +2 -0
  127. weaverstack-0.1.1.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,625 @@
1
+ """Render one item's frozen physical prune, drop, schema and build stages.
2
+
3
+ Every function here plans for exactly one logical item against exactly one bound
4
+ target. That is the shape multi-item build needs: the item graph orders the items,
5
+ and inside an item the document graph orders the work, so nothing here reaches
6
+ across items or chooses a sequence number.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ from dataclasses import dataclass, replace
13
+ from typing import Mapping
14
+
15
+ from ..declaration.metadata import DELTA_TARGET, FOLDER, SQL_TARGET, TABLE, VIEW
16
+ from ..declaration.model import WeaverItemId
17
+ from ..errors import BuildError
18
+ from ..spark.tokens import object_token
19
+ from ..etl import FILE_TYPE, PROCEDURE_TYPE, item_load_artefacts
20
+ from .changes import (
21
+ FILE as FILE_KIND,
22
+ FOLDER as FOLDER_KIND,
23
+ SCHEMA as SCHEMA_KIND,
24
+ STORED_PROCEDURE as PROCEDURE_KIND,
25
+ TABLE as TABLE_KIND,
26
+ VIEW as VIEW_KIND,
27
+ added as change_added,
28
+ removed as change_removed,
29
+ )
30
+ from .models import (
31
+ BUILD_FOLDER,
32
+ BUILD_PROCEDURE,
33
+ DELETE_FILE,
34
+ DROP_PROCEDURE,
35
+ WRITE_FILE,
36
+ BUILD_TABLE,
37
+ BUILD_VIEW,
38
+ CREATE_SCHEMA,
39
+ DROP_FOLDER,
40
+ DROP_TABLE,
41
+ DROP_VIEW,
42
+ BuildAction,
43
+ BuildBatch,
44
+ )
45
+ from .payloads import sha256_hex
46
+ from .prune import managed_sets, render_inventory_prune
47
+ from .stages import BUILD, DROP, LOAD, PRUNE, SCHEMA, PlannedStage
48
+ from .targets import WAREHOUSE_TARGET
49
+
50
+ _OBJECT_KIND = {TABLE: BUILD_TABLE, VIEW: BUILD_VIEW}
51
+ _DROP_KIND = {FOLDER: DROP_FOLDER, TABLE: DROP_TABLE, VIEW: DROP_VIEW}
52
+ _DECLARATION_KIND = {"folder": FOLDER, "table": TABLE, "view": VIEW}
53
+
54
+ #: A Weaver document kind, and a Registry object type, as the change vocabulary
55
+ #: spells them. Two mappings rather than one because the two inputs are
56
+ #: different: a build knows what it declares, a drop knows what is installed.
57
+ _CHANGE_KIND_FOR_KIND = {FOLDER: FOLDER_KIND, TABLE: TABLE_KIND, VIEW: VIEW_KIND}
58
+ _CHANGE_KIND_FOR_TYPE = {
59
+ "folder": FOLDER_KIND,
60
+ "table": TABLE_KIND,
61
+ "view": VIEW_KIND,
62
+ }
63
+
64
+ #: Kinds that change Delta storage, and therefore leave a Lakehouse's SQL
65
+ #: analytics endpoint describing something that is no longer there.
66
+ DELTA_MUTATING_KINDS = frozenset(
67
+ {
68
+ BUILD_TABLE,
69
+ BUILD_VIEW,
70
+ DROP_TABLE,
71
+ DROP_VIEW,
72
+ "prune_table",
73
+ "prune_view",
74
+ "prune_schema",
75
+ }
76
+ )
77
+
78
+
79
+ def _slug(value) -> str:
80
+ """An identity as something safe to name a payload file and an action after.
81
+
82
+ Separators, spaces and the shape marker's colon all go: a payload path must
83
+ stay relative and inside the bundle, and a colon reads as a drive letter on
84
+ one of the platforms a bundle is unpacked on.
85
+ """
86
+
87
+ return str(value).replace("/", "--").replace(" ", "-").replace(":", "-")
88
+
89
+
90
+ def item_prune_stage(
91
+ repository,
92
+ selected_ids,
93
+ *,
94
+ item: WeaverItemId,
95
+ target,
96
+ inventory,
97
+ ) -> PlannedStage | None:
98
+ """Freeze one item's authoritative repository/inventory diff.
99
+
100
+ The keep-set is derived here rather than handed in, and the load artefacts
101
+ are why. They contribute the ``_`` schema a Warehouse's generated procedures
102
+ live in, which no document declares — so a caller that did not think to pass
103
+ them would produce a prune that drops the schema the same build just created.
104
+ A destructive default is not something to leave reachable, and the repository
105
+ is already here, so nothing has to be remembered.
106
+ """
107
+
108
+ documents = {
109
+ str(identity): repository.source_documents[identity]
110
+ for identity in selected_ids
111
+ if identity.item == item
112
+ }
113
+ target_kind = SQL_TARGET if target.kind == WAREHOUSE_TARGET else DELTA_TARGET
114
+ managed = managed_sets(
115
+ documents,
116
+ target_kind,
117
+ alias_destinations=[
118
+ alias.destination
119
+ for alias in repository.aliases
120
+ if alias.destination.item == item
121
+ ],
122
+ load_identities=[
123
+ artefact.identity for artefact in item_load_artefacts(repository, item=item)
124
+ ],
125
+ )
126
+
127
+ payloads: dict[str, bytes] = {}
128
+ actions, changes = render_inventory_prune(target, inventory, managed, payloads)
129
+ if not actions:
130
+ return None
131
+
132
+ # Two items pruning a same-named object share the merged stage's payload
133
+ # directory, so each item's frozen drops carry its own prefix.
134
+ item_slug = _slug(item)
135
+ return PlannedStage(
136
+ phase=PRUNE,
137
+ slug="item-prune",
138
+ description="prune unmanaged objects by logical item",
139
+ payloads={f"{item_slug}-{name}": data for name, data in payloads.items()},
140
+ changes={
141
+ target.id: tuple(
142
+ replace(change, action_id=f"{item_slug}-{change.action_id}")
143
+ for change in changes
144
+ )
145
+ },
146
+ batches=(
147
+ BuildBatch(
148
+ id=f"item-prune-{item_slug}",
149
+ target_id=target.id,
150
+ actions=tuple(
151
+ _prefixed(action, item_slug) for action in actions
152
+ ),
153
+ ),
154
+ ),
155
+ )
156
+
157
+
158
+ def _prefixed(action: BuildAction, item_slug: str) -> BuildAction:
159
+ return replace(
160
+ action,
161
+ id=f"{item_slug}-{action.id}",
162
+ payload=None if action.payload is None else f"{item_slug}-{action.payload}",
163
+ )
164
+
165
+
166
+ def item_drop_stages(
167
+ repository,
168
+ selected_for_drop,
169
+ *,
170
+ item: WeaverItemId,
171
+ target,
172
+ registered,
173
+ ) -> tuple[PlannedStage, ...]:
174
+ """One item's managed drops, dependants before dependencies."""
175
+
176
+ selected = {identity for identity in selected_for_drop if identity.item == item}
177
+ if not selected:
178
+ return ()
179
+ graph = repository.dependency_graph.subgraph({str(value) for value in selected})
180
+ identities = {str(identity): identity for identity in selected}
181
+ stages = []
182
+ for index, layer in enumerate(reversed(graph.layers())):
183
+ payloads: dict[str, bytes] = {}
184
+ changes: list = []
185
+ actions = []
186
+ for node in sorted(layer):
187
+ identity = identities[node]
188
+ installed = registered[identity].object_type
189
+ actions.append(_drop_action(identity, installed, target, payloads))
190
+ changes.append(
191
+ change_removed(
192
+ _CHANGE_KIND_FOR_TYPE[installed],
193
+ identity.object_id.qualified,
194
+ f"managed-drop-{_slug(identity)}",
195
+ )
196
+ )
197
+ actions = tuple(actions)
198
+ stages.append(
199
+ PlannedStage(
200
+ phase=DROP,
201
+ index=index,
202
+ slug="managed-drop",
203
+ description="drop selected rebuild dependency layer",
204
+ payloads=payloads,
205
+ changes={target.id: tuple(changes)},
206
+ batches=(
207
+ BuildBatch(
208
+ id=f"managed-drop-{_slug(item)}",
209
+ target_id=target.id,
210
+ actions=actions,
211
+ ),
212
+ ),
213
+ )
214
+ )
215
+ return tuple(stages)
216
+
217
+
218
+ def _drop_action(identity, installed_type, target, payloads) -> BuildAction:
219
+ try:
220
+ installed_kind = _DECLARATION_KIND[installed_type]
221
+ except KeyError as exc:
222
+ raise BuildError(
223
+ f"registered document {identity} has unsupported type {installed_type!r}"
224
+ ) from exc
225
+ kind = _DROP_KIND[installed_kind]
226
+ action_slug = _slug(identity)
227
+ if installed_kind == FOLDER:
228
+ return BuildAction(
229
+ id=f"managed-drop-{action_slug}",
230
+ kind=kind,
231
+ resource_node_id=str(identity),
232
+ executor="folder",
233
+ payload=None,
234
+ payload_sha256=None,
235
+ )
236
+
237
+ schema = identity.object_id.schema
238
+ name = identity.object_id.object
239
+ if target.kind == WAREHOUSE_TARGET:
240
+ keyword = "view" if installed_kind == VIEW else "table"
241
+ statement = f"drop {keyword} {_tsql_ident(schema)}.{_tsql_ident(name)};\n"
242
+ executor, extension = "tsql", ".sql"
243
+ else:
244
+ keyword = "VIEW" if installed_kind == VIEW else "TABLE"
245
+ statement = f"DROP {keyword} {object_token(schema, name)}\n"
246
+ executor, extension = "spark_sql", ".spark.sql"
247
+ content = statement.encode("utf-8")
248
+ filename = f"drop-{action_slug}{extension}"
249
+ payloads[filename] = content
250
+ return BuildAction(
251
+ id=f"managed-drop-{action_slug}",
252
+ kind=kind,
253
+ resource_node_id=str(identity),
254
+ executor=executor,
255
+ payload=filename,
256
+ payload_sha256=sha256_hex(content),
257
+ )
258
+
259
+
260
+ def _tsql_ident(name: str) -> str:
261
+ return "[" + name.replace("]", "]]") + "]"
262
+
263
+
264
+ def item_schema_stage(
265
+ selected_ids,
266
+ *,
267
+ item: WeaverItemId,
268
+ target,
269
+ inventory,
270
+ extra_schemas=(),
271
+ ) -> PlannedStage | None:
272
+ """The schemas this item needs and its target does not already hold.
273
+
274
+ ``extra_schemas`` carries the schemas the item's planned aliases land in.
275
+ They are the item's own declared schemas — an alias destination has to be —
276
+ but no *document* of the item need live in them, so a namespace an alias
277
+ depends on would otherwise never be created.
278
+ """
279
+
280
+ present = {schema.casefold() for schema in inventory.schemas}
281
+ wanted = {
282
+ identity.object_id.schema
283
+ for identity in selected_ids
284
+ if identity.item == item and not identity.is_files
285
+ } | set(extra_schemas)
286
+ schemas = sorted(
287
+ schema for schema in wanted if schema.casefold() not in present
288
+ )
289
+ if not schemas:
290
+ return None
291
+
292
+ item_slug = _slug(item)
293
+ payloads: dict[str, bytes] = {}
294
+ actions = []
295
+ changes = []
296
+ for schema in schemas:
297
+ if target.kind == WAREHOUSE_TARGET:
298
+ content = f"create schema [{schema.replace(']', ']]')}];\n".encode("utf-8")
299
+ executor, extension = "tsql", ".sql"
300
+ else:
301
+ content = (json.dumps({"schema": schema}, sort_keys=True) + "\n").encode()
302
+ executor, extension = "spark_schema", ".schema.json"
303
+ filename = f"create-{item_slug}-{schema}{extension}"
304
+ payloads[filename] = content
305
+ action_id = f"schema-{item_slug}-{schema}"
306
+ actions.append(
307
+ BuildAction(
308
+ id=action_id,
309
+ kind=CREATE_SCHEMA,
310
+ resource_node_id=None,
311
+ executor=executor,
312
+ payload=filename,
313
+ payload_sha256=sha256_hex(content),
314
+ )
315
+ )
316
+ changes.append(change_added(SCHEMA_KIND, schema, action_id))
317
+ return PlannedStage(
318
+ phase=SCHEMA,
319
+ slug="create-schemas",
320
+ description="create item-owned schemas",
321
+ payloads=payloads,
322
+ changes={target.id: tuple(changes)},
323
+ batches=(
324
+ BuildBatch(id=f"{item_slug}", target_id=target.id, actions=tuple(actions)),
325
+ ),
326
+ )
327
+
328
+
329
+ @dataclass(frozen=True)
330
+ class RenderedAction:
331
+ """One authored document turned into one action and its frozen payload.
332
+
333
+ The smallest unit the build has: a declaration in, an executable out. Kept as
334
+ a value rather than written straight into a stage's payload dict so that the
335
+ mapping from *declaration* to *bundle action* — which executor runs it, what
336
+ the payload is called, what its hash is — can be examined on its own, without
337
+ planning an item or generating a bundle to see it.
338
+
339
+ ``payloads`` is empty for a folder, which is created rather than executed.
340
+ """
341
+
342
+ action: BuildAction
343
+ payloads: Mapping[str, bytes]
344
+
345
+
346
+ def render_document_build_action(identity, source) -> RenderedAction:
347
+ """The build action and payload one declared document renders to.
348
+
349
+ This is where ``source.create_ddl()`` becomes something a bundle can carry:
350
+ the DDL says *what statement*, and this says what action runs it, under what
351
+ id, with which executor, and against which frozen bytes. The two are separate
352
+ claims and are worth failing separately.
353
+ """
354
+
355
+ action_slug = _slug(identity)
356
+ if source.kind == FOLDER:
357
+ # A folder has no statement to run: it is a directory the installer
358
+ # creates, so there is nothing to freeze and nothing to hash.
359
+ return RenderedAction(
360
+ action=BuildAction(
361
+ id=f"folder-{action_slug}",
362
+ kind=BUILD_FOLDER,
363
+ resource_node_id=str(identity),
364
+ executor="folder",
365
+ payload=None,
366
+ payload_sha256=None,
367
+ ),
368
+ payloads={},
369
+ )
370
+ ddl = source.create_ddl()
371
+ filename = f"{action_slug}{ddl.extension}"
372
+ content = ddl.content.encode("utf-8")
373
+ return RenderedAction(
374
+ action=BuildAction(
375
+ id=f"object-{action_slug}",
376
+ kind=_OBJECT_KIND[source.kind],
377
+ resource_node_id=str(identity),
378
+ executor=ddl.executor,
379
+ payload=filename,
380
+ payload_sha256=sha256_hex(content),
381
+ ),
382
+ payloads={filename: content},
383
+ )
384
+
385
+
386
+ def render_load_build_action(artefact) -> RenderedAction:
387
+ """The action and frozen payload one load artefact installs as.
388
+
389
+ The load half of :func:`render_document_build_action`, and the same claim:
390
+ what runs it, under what id, against which bytes. A file is written into the
391
+ runtime tree by the ``load_file`` executor; a procedure is a create-or-alter
392
+ run by ``tsql``, which needs no knowledge that it happens to be a procedure.
393
+ """
394
+
395
+ action_slug = _slug(artefact.identity)
396
+ if artefact.is_file:
397
+ filename = f"{action_slug}.payload"
398
+ executor, kind = "load_file", WRITE_FILE
399
+ else:
400
+ filename = f"{action_slug}.sql"
401
+ executor, kind = "tsql", BUILD_PROCEDURE
402
+ return RenderedAction(
403
+ action=BuildAction(
404
+ id=f"load-{action_slug}",
405
+ kind=kind,
406
+ resource_node_id=str(artefact.identity),
407
+ executor=executor,
408
+ payload=filename,
409
+ payload_sha256=sha256_hex(artefact.payload),
410
+ ),
411
+ payloads={filename: artefact.payload},
412
+ )
413
+
414
+
415
+ def item_load_stages(
416
+ artefacts,
417
+ selected_for_build,
418
+ *,
419
+ item: WeaverItemId,
420
+ target,
421
+ ) -> tuple[PlannedStage, ...]:
422
+ """One item's load layer: the last thing it does, and one barrier wide.
423
+
424
+ A single stage rather than dependency layers, because there are no
425
+ dependencies to express — nothing here runs anything, so a deployed module
426
+ and a generated procedure have no ordering between them. What they *do*
427
+ depend on is the item's structural work, and that is expressed by the layer
428
+ being last rather than by an edge.
429
+
430
+ Empty when the item has no selected load work, which is a phase with nothing
431
+ to do rather than an empty barrier: an unpopulated stage takes no sequence
432
+ number.
433
+ """
434
+
435
+ selected = [
436
+ artefact
437
+ for artefact in artefacts
438
+ if artefact.identity.item == item and artefact.identity in selected_for_build
439
+ ]
440
+ if not selected:
441
+ return ()
442
+ payloads: dict[str, bytes] = {}
443
+ actions = []
444
+ changes = []
445
+ for artefact in sorted(selected, key=lambda value: str(value.identity)):
446
+ rendered = render_load_build_action(artefact)
447
+ payloads.update(rendered.payloads)
448
+ actions.append(rendered.action)
449
+ changes.append(
450
+ change_added(
451
+ FILE_KIND if artefact.is_file else PROCEDURE_KIND,
452
+ artefact.target_path
453
+ if artefact.is_file
454
+ else artefact.identity.object_id.qualified,
455
+ rendered.action.id,
456
+ )
457
+ )
458
+ return (
459
+ PlannedStage(
460
+ phase=LOAD,
461
+ slug="load",
462
+ description="install load artefacts",
463
+ payloads=payloads,
464
+ changes={target.id: tuple(changes)},
465
+ batches=(
466
+ BuildBatch(
467
+ id=f"{_slug(item)}", target_id=target.id, actions=tuple(actions)
468
+ ),
469
+ ),
470
+ ),
471
+ )
472
+
473
+
474
+ def item_load_removals(
475
+ removed,
476
+ *,
477
+ item: WeaverItemId,
478
+ target,
479
+ registered,
480
+ ) -> tuple[PlannedStage, ...]:
481
+ """Frozen removals for load artefacts the source has stopped claiming.
482
+
483
+ Driven by the previous Registry rows rather than by a diff against the
484
+ target, and that is what makes a rename ordinary: the old identity is no
485
+ longer claimed, so its row names exactly what to remove and where, while the
486
+ new identity is simply new. Nothing has to notice that the two are related.
487
+
488
+ The removals ride in the item's load layer alongside its writes. They cannot
489
+ collide — an identity is either still claimed or not — and keeping them
490
+ together means everything the load layer does to a target is in one barrier.
491
+ """
492
+
493
+ # Scoped by what the Registry says each removed object *is*, not by what its
494
+ # identity looks like. A removed table is removed by the inventory prune,
495
+ # which can see it; only the two the prune cannot see are handled here.
496
+ selected = sorted(
497
+ (
498
+ identity
499
+ for identity in removed
500
+ if identity.item == item
501
+ and registered[identity].object_type in (FILE_TYPE, PROCEDURE_TYPE)
502
+ ),
503
+ key=str,
504
+ )
505
+ payloads: dict[str, bytes] = {}
506
+ actions = []
507
+ changes = []
508
+ for identity in selected:
509
+ object_type = registered[identity].object_type
510
+ action_slug = _slug(identity)
511
+ if object_type == FILE_TYPE:
512
+ actions.append(
513
+ BuildAction(
514
+ id=f"load-remove-{action_slug}",
515
+ kind=DELETE_FILE,
516
+ resource_node_id=str(identity),
517
+ executor="load_file",
518
+ payload=None,
519
+ payload_sha256=None,
520
+ )
521
+ )
522
+ changes.append(
523
+ change_removed(
524
+ FILE_KIND,
525
+ f"{identity.object_id.schema}/{identity.object_id.object}",
526
+ f"load-remove-{action_slug}",
527
+ )
528
+ )
529
+ continue
530
+ statement = (
531
+ "drop procedure if exists "
532
+ f"{_tsql_ident(identity.object_id.schema)}."
533
+ f"{_tsql_ident(identity.object_id.object)};\n"
534
+ )
535
+ content = statement.encode("utf-8")
536
+ filename = f"drop-{action_slug}.sql"
537
+ payloads[filename] = content
538
+ actions.append(
539
+ BuildAction(
540
+ id=f"load-remove-{action_slug}",
541
+ kind=DROP_PROCEDURE,
542
+ resource_node_id=str(identity),
543
+ executor="tsql",
544
+ payload=filename,
545
+ payload_sha256=sha256_hex(content),
546
+ )
547
+ )
548
+ changes.append(
549
+ change_removed(
550
+ PROCEDURE_KIND,
551
+ identity.object_id.qualified,
552
+ f"load-remove-{action_slug}",
553
+ )
554
+ )
555
+ if not actions:
556
+ return ()
557
+ return (
558
+ PlannedStage(
559
+ phase=LOAD,
560
+ slug="load",
561
+ description="install load artefacts",
562
+ payloads=payloads,
563
+ changes={target.id: tuple(changes)},
564
+ batches=(
565
+ BuildBatch(
566
+ id=f"remove-{_slug(item)}",
567
+ target_id=target.id,
568
+ actions=tuple(actions),
569
+ ),
570
+ ),
571
+ ),
572
+ )
573
+
574
+
575
+ def item_build_stages(
576
+ repository,
577
+ selected_for_build,
578
+ *,
579
+ item: WeaverItemId,
580
+ target,
581
+ ) -> tuple[PlannedStage, ...]:
582
+ """One item's declared documents, in forward dependency layers."""
583
+
584
+ selected = {identity for identity in selected_for_build if identity.item == item}
585
+ if not selected:
586
+ return ()
587
+ graph = repository.dependency_graph.subgraph({str(value) for value in selected})
588
+ identities = {str(identity): identity for identity in selected}
589
+ stages = []
590
+ for index, layer in enumerate(graph.layers()):
591
+ payloads: dict[str, bytes] = {}
592
+ actions = []
593
+ changes = []
594
+ for node in sorted(layer):
595
+ identity = identities[node]
596
+ source = repository.source_documents[identity]
597
+ rendered = render_document_build_action(identity, source)
598
+ payloads.update(rendered.payloads)
599
+ actions.append(rendered.action)
600
+ changes.append(
601
+ change_added(
602
+ _CHANGE_KIND_FOR_KIND[source.kind],
603
+ identity.object_id.qualified,
604
+ rendered.action.id,
605
+ )
606
+ )
607
+ actions = tuple(actions)
608
+ stages.append(
609
+ PlannedStage(
610
+ phase=BUILD,
611
+ index=index,
612
+ slug="build-objects",
613
+ description="build dependency layer",
614
+ payloads=payloads,
615
+ changes={target.id: tuple(changes)},
616
+ batches=(
617
+ BuildBatch(
618
+ id=f"{_slug(item)}", target_id=target.id, actions=actions
619
+ ),
620
+ ),
621
+ )
622
+ )
623
+ return tuple(stages)
624
+
625
+