Complete reversible 1C saved-state prepare workflow

This commit is contained in:
2026-07-26 16:49:06 +03:00
parent 9fd4e1ff99
commit a9f95f6328
5 changed files with 431 additions and 17 deletions
+8 -6
View File
@@ -308,18 +308,20 @@ dimension, or resource, also pass `member_ref` or `member_kind` +
`member_name`; the adapter resolves the exact parent/member GUIDs and `member_name`; the adapter resolves the exact parent/member GUIDs and
serialized tree path internally. The method supports `plan`, `apply`, serialized tree path internally. The method supports `plan`, `apply`,
`apply_and_verify`, and `apply_and_rollback`, requires explicit saved-state `apply_and_verify`, and `apply_and_rollback`, requires explicit saved-state
gates, and never writes active `Config`/`ConfigCAS`. Renaming an object and gates, and never writes active `Config`/`ConfigCAS`. Renaming an object or
member, adding/removing collection items, and adding a new synonym locale member, removing collection items, and adding a new synonym locale remain
remain intentionally disabled. intentionally disabled.
`metadata.object.member.add` adds one new object requisite or tabular-section `metadata.object.member.add` adds one new object requisite or tabular-section
column (`Attribute`) by cloning an existing attribute in the same collection. column (`Attribute`) by cloning an existing attribute in the same collection.
The caller passes only The caller passes only
`template_member_ref`, `new_member_name`, and optionally synonym/comment; the `template_member_ref`, `new_member_name`, and optionally synonym/comment; the
adapter generates the GUID, preserves the template's type/settings, appends to adapter generates the GUID, preserves the template's type/settings, appends to
the exact declared collection, and verifies the new identity after apply. the exact declared collection, and verifies the new identity, empty/default
Arbitrary type construction and deletion are not supported by this first comment, container, and preserved non-identity settings after apply. A template
structural route. is blocked when its GUID or name is referenced outside its declared identity
fields. Arbitrary type construction and deletion are not supported by this
first structural route.
Managed form bodies in base `Config` are resolved from the public form GUID to Managed form bodies in base `Config` are resolved from the public form GUID to
the sibling `<guid>.0` SQL payload. Command-bar buttons expose public command the sibling `<guid>.0` SQL payload. Command-bar buttons expose public command
+361 -10
View File
@@ -1005,7 +1005,7 @@ METHOD_INPUT_SCHEMAS["metadata.object.member.add"] = {
}, },
"new_member_name": {"type": "string", "description": "New 1C Attribute name."}, "new_member_name": {"type": "string", "description": "New 1C Attribute name."},
"new_member_synonym": {"type": "string", "description": "Optional ru synonym; defaults to the new name."}, "new_member_synonym": {"type": "string", "description": "Optional ru synonym; defaults to the new name."},
"new_member_comment": {"type": "string", "description": "Optional comment; otherwise inherited from the template."}, "new_member_comment": {"type": "string", "description": "Optional comment; defaults to an empty string and is never inherited from the template."},
"extension": {"type": "string"}, "extension": {"type": "string"},
"layer": {"type": "string", "enum": ["base_saved_state", "extension_saved_state"]}, "layer": {"type": "string", "enum": ["base_saved_state", "extension_saved_state"]},
"expected_sha1": {"type": "string"}, "expected_sha1": {"type": "string"},
@@ -1018,6 +1018,89 @@ METHOD_INPUT_SCHEMAS["metadata.object.member.add"] = {
"include_storage": {"type": "boolean"}, "include_storage": {"type": "boolean"},
}, },
} }
METHOD_INPUT_SCHEMAS["metadata.write"] = {
"type": "object",
"required": ["base_id"],
"additionalProperties": True,
"properties": {
"base_id": {"type": "string", "description": "Adapter base identifier."},
"target": {
"type": "object",
"additionalProperties": True,
"description": "Name-first target. Use kind/area=object|form|module|schedule plus a public ref, kind/name, or canonical_path.",
"properties": {
"kind": {"type": "string", "enum": ["object", "form", "module", "schedule"]},
"area": {"type": "string", "enum": ["object", "form", "module", "schedule"]},
"ref": {"type": "string", "description": "Public 1C object/member ref."},
"canonical_path": {"type": "string", "description": "Public 1C code or metadata path."},
"property": {"type": "string"},
"operation": {
"type": "string",
"description": "Operation such as add_attribute, property_change, replace, or upsert_routine.",
},
"template_member_ref": {"type": "string"},
"new_member_name": {"type": "string"},
},
},
"target_kind": {"type": "string", "enum": ["object", "form", "module", "schedule"]},
"ref": {"type": "string", "description": "Optional public 1C object ref."},
"kind": {"type": "string", "description": "Optional metadata kind in English or Russian."},
"name": {"type": "string", "description": "Optional exact 1C object name."},
"object_type": {"type": "string", "description": "MCP alias of kind."},
"object_name": {"type": "string", "description": "MCP alias of name."},
"extension": {"type": "string"},
"property": {"type": "string"},
"value": {},
"schedule": {"type": "object", "additionalProperties": True},
"template_member_ref": {"type": "string"},
"new_member_name": {"type": "string"},
"new_member_synonym": {"type": "string"},
"new_member_comment": {"type": "string"},
"execution_mode": {
"type": "string",
"enum": ["plan", "apply", "apply_and_verify", "apply_and_rollback"],
},
"mode": {
"type": "string",
"enum": ["plan", "apply", "apply_and_verify", "apply_and_rollback"],
},
"allow_saved_state_write": {"type": "boolean"},
"allow_sql_saved_state_prepare": {"type": "boolean"},
"allow_sql_saved_state_apply": {"type": "boolean"},
"allow_sql_saved_state_rollback": {"type": "boolean"},
},
"examples": [
{
"base_id": "upo_test",
"target": {
"area": "object",
"ref": "Catalog.Номенклатура",
"property": "synonym",
},
"value": "Номенклатура",
"mode": "plan",
},
{
"base_id": "upo_test",
"target": {
"area": "object",
"operation": "add_attribute",
"template_member_ref": "Catalog.Номенклатура.Attribute.Артикул",
"new_member_name": "КодПоставщика",
},
"mode": "plan",
},
{
"base_id": "upo_test",
"target": {
"kind": "schedule",
"ref": "ScheduledJob.ОбменДанными",
},
"schedule": {"begin_time": "09:00:00"},
"mode": "plan",
},
],
}
METHOD_INPUT_SCHEMAS["metadata.code_index.build"] = { METHOD_INPUT_SCHEMAS["metadata.code_index.build"] = {
"type": "object", "type": "object",
"required": ["base_id"], "required": ["base_id"],
@@ -3098,10 +3181,18 @@ METADATA_WRITE_CAPABILITIES = {
"form_element_properties": { "form_element_properties": {
"status": "partial_saved_state", "status": "partial_saved_state",
"targets": ["form_element", "form_command", "form_attribute"], "targets": ["form_element", "form_command", "form_attribute"],
"operations": ["plan_property_write", "apply_property_write"], "operations": [
"plan_property_write",
"apply_property_write",
"move_sibling_target",
"upsert_command_button",
"upsert_handler_routine",
"verify_command_button_links",
],
"agent_method": "metadata.write", "agent_method": "metadata.write",
"write_layer": "save", "write_layer": "save",
"gaps": ["Complex command handler bindings and inherited form properties are not fully writable yet."], "guards": ["expected_sha1", "backup", "semantic_readback", "cache_invalidation"],
"gaps": ["Cross-parent structural moves, arbitrary element deletion, complex command bindings, and inherited form properties are not fully writable yet."],
}, },
"object_metadata": { "object_metadata": {
"status": "partial_saved_state", "status": "partial_saved_state",
@@ -3109,8 +3200,8 @@ METADATA_WRITE_CAPABILITIES = {
"operations": ["write_synonym", "write_comment", "write_member_synonym", "write_member_comment", "add_attribute_from_template"], "operations": ["write_synonym", "write_comment", "write_member_synonym", "write_member_comment", "add_attribute_from_template"],
"agent_method": "metadata.write", "agent_method": "metadata.write",
"write_layer": "save", "write_layer": "save",
"guards": ["expected_old", "expected_sha1", "backup", "semantic_readback"], "guards": ["expected_old", "expected_sha1", "backup", "semantic_readback", "settings_shape_verification", "cache_invalidation"],
"gaps": ["Attribute add currently requires an existing Attribute template in the same collection. Object/member rename, arbitrary type construction, deletion, and other structural collection writes remain disabled."], "gaps": ["Attribute add currently requires a safe existing Attribute template in the same collection. Object/member rename, arbitrary type construction, deletion, Dimension/Resource/TabularSection creation, and other structural collection writes remain disabled."],
}, },
"templates": { "templates": {
"status": "read_only", "status": "read_only",
@@ -17521,6 +17612,7 @@ def apply_saved_state_prepare_copy(
file_names: list[str], file_names: list[str],
*, *,
expected_source_rows: int, expected_source_rows: int,
prepared_rows: list[dict[str, Any]],
timeout_seconds: int, timeout_seconds: int,
) -> dict[str, Any]: ) -> dict[str, Any]:
method = "metadata.saved_state.prepare" method = "metadata.saved_state.prepare"
@@ -17573,6 +17665,13 @@ def apply_saved_state_prepare_copy(
"counts": {"expected_insert_rows": expected_source_rows, "inserted_rows": inserted}, "counts": {"expected_insert_rows": expected_source_rows, "inserted_rows": inserted},
"diagnostics": {"message": "Copied row count did not match current source row count."}, "diagnostics": {"message": "Copied row count did not match current source row count."},
} }
receipt = write_saved_state_prepare_receipt(
base_id=base_id,
config=config,
source_table=source_table,
target_table=target_table,
rows=prepared_rows,
)
conn.commit() conn.commit()
except Exception as exc: except Exception as exc:
try: try:
@@ -17601,6 +17700,7 @@ def apply_saved_state_prepare_copy(
"source": {"kind": "live_sql", "database": config["database"], "table": source_table}, "source": {"kind": "live_sql", "database": config["database"], "table": source_table},
"target": {"table": target_table}, "target": {"table": target_table},
"counts": {"inserted_rows": inserted, "file_names": len(names)}, "counts": {"inserted_rows": inserted, "file_names": len(names)},
"prepare_receipt": receipt,
"cache_invalidation": invalidate_adapter_caches_after_saved_state_change( "cache_invalidation": invalidate_adapter_caches_after_saved_state_change(
base_id, base_id,
reason="saved_state_prepare", reason="saved_state_prepare",
@@ -17609,6 +17709,220 @@ def apply_saved_state_prepare_copy(
} }
def saved_state_prepare_receipt_dir() -> Path:
return storage_apply_backup_dir() / "prepare-receipts"
def write_saved_state_prepare_receipt(
*,
base_id: str,
config: dict[str, str],
source_table: str,
target_table: str,
rows: list[dict[str, Any]],
) -> dict[str, Any]:
receipt_id = uuid.uuid4().hex
created_at = datetime.now(timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z")
root = saved_state_prepare_receipt_dir()
root.mkdir(parents=True, exist_ok=True)
path = root / f"{created_at.replace(':', '').replace('-', '')}-{receipt_id}.json"
normalized_rows = [
{
key: row.get(key)
for key in ("file_name", "part_no", "data_size", "binary_bytes", "binary_sha1")
}
for row in rows
]
evidence = {
"schema": "onec_saved_state_prepare_receipt.v1",
"receipt_id": receipt_id,
"created_at_utc": created_at,
"base_id": base_id,
"source": {
"server": config.get("server"),
"database": config.get("database"),
"table": source_table,
},
"target": {"table": target_table},
"rows": normalized_rows,
"status": "prepared",
}
path.write_text(json.dumps(evidence, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
return {
"receipt_id": receipt_id,
"path": str(path),
"rows": len(normalized_rows),
}
def resolve_saved_state_prepare_receipt(receipt_id: str) -> Path | None:
normalized = str(receipt_id or "").strip().lower()
if not re.fullmatch(r"[0-9a-f]{32}", normalized):
return None
root = saved_state_prepare_receipt_dir()
matches = sorted(root.glob(f"*{normalized}.json")) if root.is_dir() else []
return matches[0] if len(matches) == 1 else None
def rollback_saved_state_prepare_receipt(
base_id: str,
receipt_id: str,
*,
timeout_seconds: int,
) -> dict[str, Any]:
path = resolve_saved_state_prepare_receipt(receipt_id)
if path is None:
return {
"schema": "onec_saved_state_prepare_rollback.v1",
"status": "not_found",
"applied": False,
"receipt_id": receipt_id,
}
try:
evidence = json.loads(path.read_text(encoding="utf-8-sig"))
except Exception as exc:
return {
"schema": "onec_saved_state_prepare_rollback.v1",
"status": "invalid_receipt",
"applied": False,
"receipt_id": receipt_id,
"diagnostics": {"message": str(exc)},
}
if str(evidence.get("base_id") or "") != str(base_id):
return {
"schema": "onec_saved_state_prepare_rollback.v1",
"status": "blocked",
"applied": False,
"error": "receipt_base_mismatch",
"receipt_id": receipt_id,
}
if evidence.get("status") == "rolled_back":
return {
"schema": "onec_saved_state_prepare_rollback.v1",
"status": "already_rolled_back",
"applied": True,
"receipt_id": receipt_id,
}
target = evidence.get("target") if isinstance(evidence.get("target"), dict) else {}
target_table = str(target.get("table") or "")
if target_table not in SAVED_STATE_SOURCE_BY_TARGET:
return {
"schema": "onec_saved_state_prepare_rollback.v1",
"status": "invalid_receipt",
"applied": False,
"error": "receipt_target_invalid",
"receipt_id": receipt_id,
}
expected_rows = [
row for row in (evidence.get("rows") or [])
if isinstance(row, dict) and row.get("file_name") and Path(str(row["file_name"])).name == str(row["file_name"])
]
file_names = sorted({str(row["file_name"]) for row in expected_rows})
if not expected_rows or not file_names:
return {
"schema": "onec_saved_state_prepare_rollback.v1",
"status": "invalid_receipt",
"applied": False,
"error": "receipt_rows_missing",
"receipt_id": receipt_id,
}
conn, config, error = connect_live_sql(base_id, "metadata.saved_state.prepare.rollback", timeout_seconds=timeout_seconds)
if error:
return error
started = time.time()
try:
cursor = conn.cursor(as_dict=True)
placeholders = ",".join(["%s"] * len(file_names))
cursor.execute(
f"""
SELECT FileName, PartNo, DataSize, DATALENGTH(BinaryData) AS BinaryBytes,
CONVERT(varchar(40), HASHBYTES('SHA1', BinaryData), 2) AS BinarySHA1
FROM dbo.[{target_table}] WITH (UPDLOCK, HOLDLOCK)
WHERE FileName IN ({placeholders})
ORDER BY FileName, PartNo
""",
tuple(file_names),
)
current_rows = [
saved_state_row_public({key: jsonable(value) for key, value in row.items()})
for row in cursor.fetchall()
]
comparable_fields = ("file_name", "part_no", "data_size", "binary_bytes", "binary_sha1")
expected_comparable = sorted(
tuple(row.get(field) for field in comparable_fields)
for row in expected_rows
)
current_comparable = sorted(
tuple(row.get(field) for field in comparable_fields)
for row in current_rows
)
if current_comparable != expected_comparable:
conn.rollback()
return {
"schema": "onec_saved_state_prepare_rollback.v1",
"status": "precondition_failed",
"applied": False,
"error": "prepared_rows_changed",
"receipt_id": receipt_id,
"counts": {
"expected_rows": len(expected_comparable),
"current_rows": len(current_comparable),
},
}
cursor.execute(
f"DELETE FROM dbo.[{target_table}] WHERE FileName IN ({placeholders})",
tuple(file_names),
)
deleted = int(cursor.rowcount or 0)
if deleted != len(expected_rows):
conn.rollback()
return {
"schema": "onec_saved_state_prepare_rollback.v1",
"status": "precondition_failed",
"applied": False,
"error": "prepared_row_delete_count_mismatch",
"receipt_id": receipt_id,
"counts": {"expected_rows": len(expected_rows), "deleted_rows": deleted},
}
conn.commit()
except Exception as exc:
try:
conn.rollback()
except Exception:
pass
return {
"schema": "onec_saved_state_prepare_rollback.v1",
"status": "error",
"applied": False,
"receipt_id": receipt_id,
"diagnostics": {"message": str(exc)},
}
finally:
try:
conn.close()
except Exception:
pass
evidence["status"] = "rolled_back"
evidence["rolled_back_at_utc"] = datetime.now(timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z")
path.write_text(json.dumps(evidence, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
return {
"schema": "onec_saved_state_prepare_rollback.v1",
"status": "rolled_back",
"applied": True,
"base_id": base_id,
"receipt_id": receipt_id,
"target": {"table": target_table},
"counts": {"deleted_rows": deleted, "file_names": len(file_names)},
"cache_invalidation": invalidate_adapter_caches_after_saved_state_change(
base_id,
reason="saved_state_prepare_rollback",
),
"duration_ms": int((time.time() - started) * 1000),
}
def public_saved_state_prepare_object(object_card: dict[str, Any] | None) -> dict[str, Any] | None: def public_saved_state_prepare_object(object_card: dict[str, Any] | None) -> dict[str, Any] | None:
if not isinstance(object_card, dict): if not isinstance(object_card, dict):
return None return None
@@ -17880,6 +18194,7 @@ def metadata_saved_state_prepare(payload: dict[str, Any]) -> dict[str, Any]:
target_table, target_table,
file_names, file_names,
expected_source_rows=len(source_rows), expected_source_rows=len(source_rows),
prepared_rows=public_source_rows,
timeout_seconds=timeout_seconds, timeout_seconds=timeout_seconds,
) )
result["apply_result"] = apply_result result["apply_result"] = apply_result
@@ -42579,6 +42894,9 @@ def metadata_object_property_write(payload: dict[str, Any]) -> dict[str, Any]:
"diagnostics": prepare_result.get("diagnostics") or {"message": "Could not resolve a writable saved-state copy for the named object."}, "diagnostics": prepare_result.get("diagnostics") or {"message": "Could not resolve a writable saved-state copy for the named object."},
} }
return result if include_storage else sanitize_object_property_write_result(result) return result if include_storage else sanitize_object_property_write_result(result)
prepare_apply_result = prepare_result.get("apply_result") if isinstance(prepare_result.get("apply_result"), dict) else {}
prepare_receipt = prepare_apply_result.get("prepare_receipt") if isinstance(prepare_apply_result.get("prepare_receipt"), dict) else {}
prepare_receipt_id = str(prepare_receipt.get("receipt_id") or "")
object_card = prepare_result.get("object") if isinstance(prepare_result.get("object"), dict) else {} object_card = prepare_result.get("object") if isinstance(prepare_result.get("object"), dict) else {}
object_guid = str(object_card.get("guid") or payload.get("guid") or payload.get("object_guid") or "").strip().lower() object_guid = str(object_card.get("guid") or payload.get("guid") or payload.get("object_guid") or "").strip().lower()
@@ -42787,7 +43105,22 @@ def metadata_object_property_write(payload: dict[str, Any]) -> dict[str, Any]:
} }
) )
result["rollback_result"] = rollback_result result["rollback_result"] = rollback_result
result["rolled_back"] = bool(rollback_result.get("applied")) payload_rolled_back = bool(rollback_result.get("applied"))
prepare_rollback_result = None
if payload_rolled_back and prepare_receipt_id:
prepare_rollback_result = rollback_saved_state_prepare_receipt(
base_id,
prepare_receipt_id,
timeout_seconds=timeout_seconds,
)
result["prepare_rollback_result"] = prepare_rollback_result
result["rolled_back"] = bool(
payload_rolled_back
and (
not prepare_receipt_id
or (prepare_rollback_result or {}).get("applied")
)
)
result["status"] = ( result["status"] = (
"verified_and_rolled_back" "verified_and_rolled_back"
if result["applied"] and verified and result["rolled_back"] if result["applied"] and verified and result["rolled_back"]
@@ -42903,6 +43236,9 @@ def metadata_object_member_add(payload: dict[str, Any]) -> dict[str, Any]:
"prepare_result": prepare_result, "prepare_result": prepare_result,
} }
return result if include_storage else sanitize_object_property_write_result(result) return result if include_storage else sanitize_object_property_write_result(result)
prepare_apply_result = prepare_result.get("apply_result") if isinstance(prepare_result.get("apply_result"), dict) else {}
prepare_receipt = prepare_apply_result.get("prepare_receipt") if isinstance(prepare_apply_result.get("prepare_receipt"), dict) else {}
prepare_receipt_id = str(prepare_receipt.get("receipt_id") or "")
object_card = prepare_result.get("object") if isinstance(prepare_result.get("object"), dict) else {} object_card = prepare_result.get("object") if isinstance(prepare_result.get("object"), dict) else {}
object_guid = str(object_card.get("guid") or payload.get("guid") or payload.get("object_guid") or "").strip().lower() object_guid = str(object_card.get("guid") or payload.get("guid") or payload.get("object_guid") or "").strip().lower()
@@ -43148,7 +43484,22 @@ def metadata_object_member_add(payload: dict[str, Any]) -> dict[str, Any]:
} }
) )
result["rollback_result"] = rollback_result result["rollback_result"] = rollback_result
result["rolled_back"] = bool(rollback_result.get("applied")) payload_rolled_back = bool(rollback_result.get("applied"))
prepare_rollback_result = None
if payload_rolled_back and prepare_receipt_id:
prepare_rollback_result = rollback_saved_state_prepare_receipt(
base_id,
prepare_receipt_id,
timeout_seconds=timeout_seconds,
)
result["prepare_rollback_result"] = prepare_rollback_result
result["rolled_back"] = bool(
payload_rolled_back
and (
not prepare_receipt_id
or (prepare_rollback_result or {}).get("applied")
)
)
result["status"] = "verified_and_rolled_back" if verified and result["rolled_back"] else "applied_rollback_failed" result["status"] = "verified_and_rolled_back" if verified and result["rolled_back"] else "applied_rollback_failed"
return result if include_storage else sanitize_object_property_write_result(result) return result if include_storage else sanitize_object_property_write_result(result)
@@ -52211,14 +52562,14 @@ def validate_metadata_write_payload(payload: dict[str, Any]) -> dict[str, Any] |
target_dict = target if isinstance(target, dict) else {} target_dict = target if isinstance(target, dict) else {}
route_kind = payload.get("target_kind") or target_dict.get("target_kind") or target_dict.get("area") or payload.get("area") route_kind = payload.get("target_kind") or target_dict.get("target_kind") or target_dict.get("area") or payload.get("area")
if not route_kind and str(target_dict.get("kind") or "").strip().casefold() in { if not route_kind and str(target_dict.get("kind") or "").strip().casefold() in {
"form", "форма", "module", "модуль", "bsl", "object", "объект", "metadata", "метаданные" "form", "форма", "module", "модуль", "bsl", "object", "объект", "metadata", "метаданные", "schedule", "расписание"
}: }:
route_kind = target_dict.get("kind") route_kind = target_dict.get("kind")
if not route_kind and str(payload.get("kind") or "").strip().casefold() in {"form", "форма", "module", "модуль", "bsl"}: if not route_kind and str(payload.get("kind") or "").strip().casefold() in {"form", "форма", "module", "модуль", "bsl"}:
route_kind = payload.get("kind") route_kind = payload.get("kind")
target_kind = str(route_kind or "form").strip().casefold() target_kind = str(route_kind or "form").strip().casefold()
if target_kind not in {"form", "форма", "module", "модуль", "bsl", "object", "объект", "metadata", "метаданные"}: if target_kind not in {"form", "форма", "module", "модуль", "bsl", "object", "объект", "metadata", "метаданные", "schedule", "расписание"}:
return invalid_argument(method, "target.kind", "Only form, module, and object-property saved-state writes are currently routed.", allowed_values=["form", "module", "object"]) return invalid_argument(method, "target.kind", "Only form, module, object, and scheduled-job schedule saved-state writes are currently routed.", allowed_values=["form", "module", "object", "schedule"])
mode = str(payload.get("execution_mode") or payload.get("mode") or "plan").strip().lower() mode = str(payload.get("execution_mode") or payload.get("mode") or "plan").strip().lower()
if mode not in FORM_ELEMENT_WRITE_APPLY_MODES: if mode not in FORM_ELEMENT_WRITE_APPLY_MODES:
return invalid_argument(method, "execution_mode", "Unsupported execution mode.", allowed_values=sorted(FORM_ELEMENT_WRITE_APPLY_MODES)) return invalid_argument(method, "execution_mode", "Unsupported execution mode.", allowed_values=sorted(FORM_ELEMENT_WRITE_APPLY_MODES))
+26
View File
@@ -394,6 +394,32 @@ TOOLS = [
"mode": "plan", "mode": "plan",
}, },
}, },
{
"method": "metadata.write",
"payload": {
"base_id": "<base_id-from-project-context>",
"target": {
"area": "object",
"ref": "Справочник.<object-name>",
"property": "synonym",
},
"value": "<new-synonym>",
"mode": "plan",
},
},
{
"method": "metadata.write",
"payload": {
"base_id": "<base_id-from-project-context>",
"target": {
"area": "object",
"operation": "add_attribute",
"template_member_ref": "Справочник.<object-name>.Реквизит.<template-attribute>",
"new_member_name": "<new-attribute-name>",
},
"mode": "plan",
},
},
{ {
"method": "metadata.form.command_button.write", "method": "metadata.form.command_button.write",
"payload": { "payload": {
+34
View File
@@ -291,6 +291,25 @@ def test_object_property_plan_writes_existing_member_by_name(monkeypatch) -> Non
def test_object_property_apply_verify_and_rollback(monkeypatch) -> None: def test_object_property_apply_verify_and_rollback(monkeypatch) -> None:
install_common_mocks(monkeypatch) install_common_mocks(monkeypatch)
monkeypatch.setattr(
adapter,
"metadata_saved_state_prepare",
lambda _payload: {
"status": "verified",
"applied": True,
"object": {
"kind": "Catalog",
"name": "Номенклатура",
"guid": OBJECT_GUID,
},
"file_names": ["object.0"],
"apply_result": {
"prepare_receipt": {
"receipt_id": "a" * 32,
}
},
},
)
reads = iter([(b"old", {"database": "db"}, None), (b"new", {"database": "db"}, None)]) reads = iter([(b"old", {"database": "db"}, None), (b"new", {"database": "db"}, None)])
monkeypatch.setattr(adapter, "read_storage_file_bytes", lambda *_args, **_kwargs: next(reads)) monkeypatch.setattr(adapter, "read_storage_file_bytes", lambda *_args, **_kwargs: next(reads))
monkeypatch.setattr( monkeypatch.setattr(
@@ -320,6 +339,19 @@ def test_object_property_apply_verify_and_rollback(monkeypatch) -> None:
"storage_saved_state_rollback", "storage_saved_state_rollback",
lambda payload: {"status": "applied", "applied": True, "backup_id": payload["backup_id"]}, lambda payload: {"status": "applied", "applied": True, "backup_id": payload["backup_id"]},
) )
prepare_rollback: dict[str, Any] = {}
def fake_prepare_rollback(base_id: str, receipt_id: str, *, timeout_seconds: int) -> dict[str, Any]:
prepare_rollback.update(
{
"base_id": base_id,
"receipt_id": receipt_id,
"timeout_seconds": timeout_seconds,
}
)
return {"status": "rolled_back", "applied": True}
monkeypatch.setattr(adapter, "rollback_saved_state_prepare_receipt", fake_prepare_rollback)
result = adapter.metadata_object_property_write( result = adapter.metadata_object_property_write(
base_payload( base_payload(
@@ -332,6 +364,8 @@ def test_object_property_apply_verify_and_rollback(monkeypatch) -> None:
assert result["status"] == "verified_and_rolled_back" assert result["status"] == "verified_and_rolled_back"
assert result["applied"] is True assert result["applied"] is True
assert result["rolled_back"] is True assert result["rolled_back"] is True
assert result["prepare_rollback_result"]["status"] == "rolled_back"
assert prepare_rollback["receipt_id"] == "a" * 32
assert result["semantic_verification"] == { assert result["semantic_verification"] == {
"status": "ok", "status": "ok",
"expected": "Номенклатура товаров", "expected": "Номенклатура товаров",
+2 -1
View File
@@ -1062,7 +1062,8 @@ def test_metadata_write_routes_schedule_target_without_changing_existing_form_mo
lambda payload: captured.update(payload) or {"schema": "onec_scheduled_job_schedule_write.v1", "status": "planned"}, lambda payload: captured.update(payload) or {"schema": "onec_scheduled_job_schedule_write.v1", "status": "planned"},
) )
result = adapter_server.metadata_write( result = adapter_server.call_method(
"metadata.write",
{ {
"base_id": "upo_test", "base_id": "upo_test",
"target": {"kind": "schedule", "ref": "РегламентныеЗадания.ОбменДанными"}, "target": {"kind": "schedule", "ref": "РегламентныеЗадания.ОбменДанными"},