textus 0.54.1 → 0.54.2

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.
@@ -8,11 +8,11 @@ Guidelines for shaping a `.textus/` tree, naming keys, organising schemas, and i
8
8
  ## Key naming
9
9
 
10
10
  - **Segments are lowercase, kebab- or snake-case.** The grammar `^[a-z0-9](?:[a-z0-9_-]*[a-z0-9])?$` is the hard limit. Prefer `acme-dashboard` over `acmedashboard` when there's a natural word break.
11
- - **Lead with the zone in the key path.** `working.projects.acme.dashboard`, not `projects.acme.dashboard`. The zone prefix makes it obvious from the key alone whether a write will be accepted.
11
+ - **Lead with the lane in the key path.** `working.projects.acme.dashboard`, not `projects.acme.dashboard`. The lane prefix makes it obvious from the key alone whether a write will be accepted.
12
12
  - **Mirror the directory structure.** If `working.projects.acme.dashboard` resolves to `working/projects/acme/dashboard.md`, do not invent shortcuts that diverge.
13
13
  - **Don't pluralise the leaf.** `working.network.org.jane`, not `working.network.org.janes`. Pluralise the container, not the entry.
14
14
 
15
- ## Zone layout
15
+ ## Lane layout
16
16
 
17
17
  Recommended top-level layout — the spec allows alternatives, but this is what tooling will default to:
18
18
 
@@ -20,12 +20,14 @@ Recommended top-level layout — the spec allows alternatives, but this is what
20
20
  .textus/
21
21
  manifest.yaml
22
22
  schemas/ # YAML schema definitions
23
- zones/
23
+ workflows/ # Textus.workflow DSL files for produced entry acquisition
24
+ templates/ # ERB templates for publish rendering
25
+ data/
24
26
  knowledge/ # authored truth: identity, voice, decisions — author-holders write
25
27
  notebook/ # agent's own durable lane (workspace) — keep-holders write
26
- feeds/ # automation-fed external inputs (fetch)
27
28
  proposals/ # AI proposals awaiting accept (propose)
28
- artifacts/ # generated by build automation — never edit by hand
29
+ artifacts/ # computed outputs produced by drain — never edit by hand
30
+ raw/ # write-once external source material (ingest)
29
31
  ```
30
32
 
31
33
  Inside `knowledge/`, group by **domain** (identity, people, projects, decisions, runbooks), not by file type or date. `knowledge.identity.*` is the convention for slow-changing identity facts. Inside `artifacts/`, group by **producer** (`artifacts/catalogs/`, `artifacts/indexes/`) so it's clear which build job owns what.
@@ -47,75 +49,57 @@ The `owner:` field in the manifest is **advisory metadata**, not an ACL. Use it
47
49
 
48
50
  Tooling around `git blame` or audit logs may filter on owner; the gem itself only echoes it back in envelopes.
49
51
 
50
- ## Derived entries
52
+ ## Produced entries
51
53
 
52
- A derived entry declares a `source:` block with a `from:` discriminator (ADR 0093/0094). A `source:` acquires **data** — it never renders; rendering is a publish concern (below). Two `from:` values for derived entries:
53
-
54
- **`source: { from: project }`** — textus computes the entry's data on `textus drain` from other store entries. Declarative; nothing shells out. Projection fields are flat under `source:`.
54
+ A produced entry declares `source: { from: external, command: "true", sources: [] }` and a matching `Textus.workflow` block in `.textus/workflows/**/*.rb`. The `source:` acquires **data** — it never renders; rendering is a publish concern (below).
55
55
 
56
56
  ```yaml
57
- - key: artifacts.derived.people
58
- path: artifacts/derived/people.md
59
- zone: artifacts
60
- schema: null
61
- owner: automation:catalog-people
62
- source:
63
- from: project
64
- select: knowledge.network.org # prefix or list of prefixes
65
- pluck: [name, relationship, org]
66
- sort_by: name
67
- format: json # the stored form is data
57
+ - key: artifacts.feeds.skills
58
+ lane: artifacts
59
+ kind: produced
60
+ format: json
61
+ source: { from: external, command: "true", sources: [] }
68
62
  publish:
69
- - { to: docs/people.md, template: people.mustache } # render the data through a template
70
- ```
71
-
72
- **`source: { from: command }`** — an external build tool (rake, just, a shell script) produces the file. textus never executes the `command:`; it only tracks `sources:` so `doctor`'s `generator_drift` check can compare source mtimes against the file's `_meta.generated.at`. The role running the build must hold `converge` (default: `automation`).
73
-
74
- ```yaml
75
- - key: artifacts.derived.skills
76
- path: artifacts/derived/skills.md
77
- zone: artifacts
78
- owner: automation:catalog-skills
79
- source:
80
- from: command
81
- command: "rake catalog:skills" # informational; the automation invokes it
82
- sources: [knowledge.projects, knowledge.network]
63
+ - { to: docs/reference/skills.md, template: feeds/skills.erb }
83
64
  ```
84
65
 
85
- The build automation is responsible for writing the `generated:` frontmatter block (`by`, `at`, `from`) when it produces the file. `generated.from` SHOULD match `source.sources` — same list, recorded twice so a diff proves what was consumed.
86
-
87
- Full contract for both shapes is in [`../../SPEC.md` §5.2.1 and §5.2.2](../../SPEC.md). Data transforms (`source.transform:`) and publishing (a `publish:` list of `{ to, template? }` / `{ tree }` targets — render or copy the data) are covered in §5.2–§5.3.
66
+ The matching workflow block:
88
67
 
89
- ## Intake and freshness
68
+ ```ruby
69
+ # .textus/workflows/feeds/agentskills.rb
70
+ Textus.workflow "agentskills" do
71
+ match "artifacts.feeds.skills"
90
72
 
91
- External inputs land via `:resolve_handler` hooks, not shell commands. Each intake entry declares `source: { from: handler, handler: <name>, ttl: <dur> }`; re-pull is system-pushed via `drain` (scheduled sweep) and `hook run` (event push) — a `get` never refreshes (ADR 0089):
73
+ step :fetch do |_, _ctx|
74
+ { "content" => { "skills" => [...], "count" => 1 } }
75
+ end
92
76
 
93
- ```sh
94
- textus pulse --output=json # `stale` lists expired entries; `next_due_at` is the soonest deadline
95
- textus drain --as=automation # re-pulls every intake entry past its source.ttl
77
+ publish
78
+ end
96
79
  ```
97
80
 
98
- The re-pull cadence is the entry's own `source.ttl`. Age-based garbage collection is the orthogonal `retention:` rule slot in the top-level `rules:` block, matched by glob (`{ ttl, action: drop | archive }`); the two compose — re-pull hourly *and* archive at 90 days:
81
+ `drain` discovers workflows, matches entries, runs the steps, and writes the result back to the entry's data path. Publishing then copies or renders each `publish:` target.
99
82
 
100
- ```yaml
101
- - key: artifacts.feeds.notion.events
102
- kind: produced # produce-method (intake) read from source.from: handler
103
- zone: artifacts
104
- source: { from: handler, handler: notion, config: { ... }, ttl: 6h }
83
+ Age-based GC uses a `retention:` rule in the top-level `rules:` block:
105
84
 
85
+ ```yaml
106
86
  rules:
107
- - match: artifacts.feeds.notion.**
87
+ - match: artifacts.feeds.**
108
88
  retention: { ttl: 90d, action: archive }
109
89
  ```
110
90
 
111
- A typical scheduled integration runs `drain` on a cron to re-pull every
112
- expired feed:
91
+ Full contract in [`../../SPEC.md` §5.2](../../SPEC.md).
92
+
93
+ ## Freshness
94
+
95
+ A `get` is always a pure read (ADR 0089) — it never triggers a re-produce:
113
96
 
114
97
  ```sh
115
- textus drain --as=automation # in cron / CI re-pulls all stale feeds
98
+ textus pulse --output=json # `stale` lists entries past their ttl
99
+ textus drain --as=automation # re-produces stale entries
116
100
  ```
117
101
 
118
- See [`./zones.md` §6](zones.md) for the full intake contract and [`../how-to/writing-hooks.md`](../how-to/writing-hooks.md) for writing custom handlers.
102
+ See [`./lanes.md`](lanes.md) for lane semantics and [`../how-to/configuring-lanes.md`](../how-to/configuring-lanes.md) for setting up produced entries and workflows.
119
103
 
120
104
  ### Read vs. refresh
121
105
 
@@ -141,11 +125,11 @@ For multi-writer environments, **always pass `if_etag`** on `put`. The gem treat
141
125
 
142
126
  The application layer is organised around three shapes — `Manifest` as a composition record, a single `Container` capability record handed to every use case, and a split envelope reader/writer. See [ADR 0018](../architecture/decisions/0018-manifest-carving.md), [ADR 0017](../architecture/decisions/0017-envelope-io-split.md), [ADR 0022](../architecture/decisions/0022-container-call-dispatcher.md), and [ADR 0023](../architecture/decisions/0023-uniform-use-case-shape.md).
143
127
 
144
- - **`Manifest` is a composition record** (`Data.define(:data, :resolver, :policy, :rules)`). Reach individual concerns through the field accessors: `manifest.data.entries`, `manifest.policy.permission_for(zone)`, `manifest.resolver.resolve(key)`, `manifest.rules.for(key)`.
128
+ - **`Manifest` is a composition record** (`Data.define(:data, :resolver, :policy, :rules)`). Reach individual concerns through the field accessors: `manifest.data.entries`, `manifest.policy.permission_for(lane)`, `manifest.resolver.resolve(key)`, `manifest.rules.for(key)`.
145
129
  - **Use cases are plain `(container:, call:)` classes.** Each is a single class under `lib/textus/{read,write,maintenance}/` with `def initialize(container:, call:)` and a `#call(...)` method; verbs are looked up in the static `Textus::Dispatcher::VERBS` table. `Container` is a `Data.define` record bundling the wired ports + manifest (`manifest`, `file_store`, `schemas`, `root`, `audit_log`, `events`, `rpc`); `Call` is the immutable per-invocation value (`role`, `correlation_id`, `now`, `dry_run`). A use case that emits events derives its `Hooks::Context` from `(container, call)` — nothing is injected. Use cases pull only what they need into ivars; nobody passes the raw `Store` around the application layer. `store.as(role)` returns a `RoleScope` that forwards verbs to the dispatcher.
146
130
  - **Write path is split**: `Envelope::IO::Reader` owns read/parse (existing-uid lookup, raw read, parse), and `Envelope::IO::Writer` owns put/delete/move + the audit-append invariant (every public method's final action is `@audit_log.append(...)`).
147
131
 
148
- The user-facing CLI surface, the wire envelope shape, and the protocol version (`textus/3`) are unchanged.
132
+ The user-facing CLI surface, the wire envelope shape, and the protocol version (`textus/4`) are unchanged.
149
133
 
150
134
  ## Pairing with other tools
151
135
 
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "fileutils"
4
4
  require "date"
5
+ require "digest"
5
6
 
6
7
  module Textus
7
8
  module Action
@@ -22,6 +23,8 @@ module Textus
22
23
  view { |env| { "key" => env.key, "uid" => env.uid, "etag" => env.etag } }
23
24
 
24
25
  SOURCE_KINDS = %w[url file asset].freeze
26
+ CONTENT_HASH_ALGO = "sha256"
27
+ TOMBSTONE_RETAIN = %w[ingested_at].freeze
25
28
 
26
29
  def initialize(kind:, slug:, url: nil, path: nil, zone: nil, label: nil)
27
30
  super()
@@ -43,7 +46,22 @@ module Textus
43
46
  action: :ingest, actor: call.role, key: key,
44
47
  )
45
48
 
46
- write_raw_entry(key, now, container, call)
49
+ content_hash = compute_content_hash
50
+ writer = Textus::Envelope::Writer.from(container: container, call: call)
51
+ mentry = container.manifest.resolver.resolve(key).entry
52
+ ts = now.iso8601
53
+ structured = build_structured(ts, container, now, content_hash)
54
+
55
+ index = Textus::Ports::RawIndex.new(root: container.root)
56
+ duplicate_key = find_duplicate(index, content_hash)
57
+
58
+ if duplicate_key && duplicate_key != key
59
+ supersede_entry(duplicate_key, key, structured, container, call, index)
60
+ else
61
+ env = write_raw_entry(key, structured, mentry, writer)
62
+ index.upsert(content_hash: content_hash, url: @url, key: key)
63
+ env
64
+ end
47
65
  end
48
66
 
49
67
  private
@@ -70,50 +88,100 @@ module Textus
70
88
  "raw.#{date}.#{@kind}-#{@slug}"
71
89
  end
72
90
 
73
- def write_raw_entry(key, now, container, call)
74
- ts = now.iso8601
75
- mentry = container.manifest.resolver.resolve(key).entry
76
- writer = Textus::Envelope::Writer.from(container: container, call: call)
91
+ def compute_content_hash
92
+ digest = Digest::SHA256.new
93
+ case @kind
94
+ when "url"
95
+ digest.update(@url)
96
+ when "file", "asset"
97
+ digest.file(@path)
98
+ end
99
+ "#{CONTENT_HASH_ALGO}:#{digest.hexdigest}"
100
+ end
77
101
 
102
+ def build_structured(timestamp, container, now, content_hash)
103
+ base = { "ingested_at" => timestamp, "content_hash" => content_hash }
78
104
  case @kind
79
105
  when "url"
80
- structured = {
81
- "ingested_at" => ts,
82
- "source" => { "kind" => "url", "url" => @url, "label" => @label || @url },
83
- "body" => nil,
84
- }
85
- writer.put(key, mentry: mentry,
86
- payload: Textus::Envelope::Writer::Payload.new(
87
- meta: nil, body: nil, content: structured,
88
- ))
106
+ base.merge("source" => { "kind" => "url", "url" => @url, "label" => @label || @url },
107
+ "body" => nil)
89
108
  when "file"
90
109
  body_content = File.read(@path)
91
- structured = {
92
- "ingested_at" => ts,
93
- "source" => { "kind" => "file", "path" => @path,
94
- "label" => @label || File.basename(@path) },
95
- "body" => body_content,
96
- }
97
- writer.put(key, mentry: mentry,
98
- payload: Textus::Envelope::Writer::Payload.new(
99
- meta: nil, body: nil, content: structured,
100
- ))
110
+ base.merge("source" => { "kind" => "file", "path" => @path,
111
+ "label" => @label || File.basename(@path) },
112
+ "body" => body_content)
101
113
  when "asset"
102
114
  asset_rel = copy_asset_file(container, now)
103
- structured = {
104
- "ingested_at" => ts,
105
- "source" => { "kind" => "asset",
106
- "label" => @label || File.basename(@path) },
107
- "asset" => asset_rel,
108
- "body" => nil,
109
- }
110
- writer.put(key, mentry: mentry,
111
- payload: Textus::Envelope::Writer::Payload.new(
112
- meta: nil, body: nil, content: structured,
113
- ))
115
+ base.merge("source" => { "kind" => "asset",
116
+ "label" => @label || File.basename(@path) },
117
+ "asset" => asset_rel,
118
+ "body" => nil)
114
119
  end
115
120
  end
116
121
 
122
+ def write_raw_entry(key, structured, mentry, writer)
123
+ writer.put(key, mentry: mentry,
124
+ payload: Textus::Envelope::Writer::Payload.new(
125
+ meta: nil, body: nil, content: structured,
126
+ ))
127
+ end
128
+
129
+ def find_duplicate(index, content_hash)
130
+ dup = index.find_by_hash(content_hash)
131
+ return dup if dup
132
+
133
+ return unless @kind == "url"
134
+
135
+ index.find_by_url(@url)
136
+ end
137
+
138
+ def supersede_entry(old_key, new_key, structured, container, call, index)
139
+ old_mentry = container.manifest.resolver.resolve(old_key).entry
140
+ writer = Textus::Envelope::Writer.from(container: container, call: call)
141
+
142
+ reader = Textus::Envelope::Reader.from(container: container)
143
+ old_env = reader.read(old_key)
144
+ old_content = old_env&.content || {}
145
+ tombstone = {}
146
+ TOMBSTONE_RETAIN.each do |k|
147
+ tombstone[k] = old_content[k] if old_content.key?(k)
148
+ end
149
+ source_kind = old_content.dig("source", "kind")
150
+ tombstone["source"] = { "kind" => source_kind } if source_kind
151
+ tombstone["superseded_by"] = new_key
152
+
153
+ writer.put(old_key, mentry: old_mentry,
154
+ payload: Textus::Envelope::Writer::Payload.new(
155
+ meta: nil, body: nil, content: tombstone,
156
+ ))
157
+
158
+ structured["supersedes"] = old_key
159
+ env = write_raw_entry(new_key, structured, container.manifest.resolver.resolve(new_key).entry, writer)
160
+
161
+ move_asset_file(container, old_content["asset"]) if @kind == "asset" && old_content["asset"]
162
+
163
+ index.upsert(content_hash: structured["content_hash"], url: @url, key: new_key)
164
+ env
165
+ end
166
+
167
+ def move_asset_file(container, old_asset_rel)
168
+ old_path = File.join(container.root, "assets", old_asset_rel)
169
+ return unless File.exist?(old_path)
170
+
171
+ now = Time.now.utc
172
+ date_path = now.strftime("%Y/%m/%d")
173
+ filename = File.basename(old_path)
174
+ new_dir = File.join(container.root, "assets", "raw", date_path, @zone)
175
+ new_path = File.join(new_dir, filename)
176
+
177
+ return if old_path == new_path
178
+
179
+ FileUtils.mkdir_p(new_dir)
180
+ FileUtils.mv(old_path, new_path)
181
+ rescue Errno::ENOENT, Errno::EACCES => e
182
+ warn "[textus ingest] could not move asset #{old_asset_rel}: #{e.message}"
183
+ end
184
+
117
185
  def copy_asset_file(container, now)
118
186
  date_path = now.strftime("%Y/%m/%d")
119
187
  filename = File.basename(@path)
@@ -46,7 +46,7 @@ module Textus
46
46
  end
47
47
 
48
48
  def index_etag(container)
49
- path = container.manifest.resolver.resolve("artifacts.index").path
49
+ path = container.manifest.resolver.resolve("artifacts.system.index").path
50
50
  File.exist?(path) ? container.file_store.etag(path) : nil
51
51
  rescue Textus::Error
52
52
  nil
data/lib/textus/boot.rb CHANGED
@@ -158,7 +158,7 @@ module Textus
158
158
  "contract_etag" => etag,
159
159
  "lanes" => lanes_for(manifest),
160
160
  "agent_quickstart" => agent_quickstart(manifest, container.audit_log),
161
- "orientation" => read_artifact_content(container, "artifacts.orientation"),
161
+ "orientation" => read_artifact_content(container, "artifacts.config.orientation"),
162
162
  "context" => read_boot_context(container),
163
163
  "index_key" => index_key_if_present(container),
164
164
  "agent_protocol" => agent_protocol(manifest),
@@ -189,8 +189,8 @@ module Textus
189
189
  end
190
190
 
191
191
  def self.index_key_if_present(container)
192
- res = container.manifest.resolver.resolve("artifacts.index")
193
- res.path && File.exist?(res.path) ? "artifacts.index" : nil
192
+ res = container.manifest.resolver.resolve("artifacts.system.index")
193
+ res.path && File.exist?(res.path) ? "artifacts.system.index" : nil
194
194
  rescue Textus::Error
195
195
  nil
196
196
  end
@@ -1,7 +1,7 @@
1
1
  require "fileutils"
2
2
 
3
3
  module Textus
4
- # Per-role cursor cache under <root>/.run/state/cursor.<role>. A convenience so
4
+ # Per-role cursor cache under <root>/.state/cursors/<role>. A convenience so
5
5
  # `textus pulse` (no --since) means "since I last looked". Gitignored;
6
6
  # losing it just re-emits recent deltas, never corrupts the store. ADR 0036/0038.
7
7
  class CursorStore
data/lib/textus/init.rb CHANGED
@@ -93,7 +93,7 @@ module Textus
93
93
 
94
94
  def self.setup_state_dirs(target_root)
95
95
  FileUtils.mkdir_p(Textus::Layout.audit_dir(target_root))
96
- FileUtils.mkdir_p(Textus::Layout.state(target_root))
96
+ FileUtils.mkdir_p(Textus::Layout.cursors(target_root))
97
97
  FileUtils.mkdir_p(Textus::Layout.locks(target_root))
98
98
  end
99
99
 
data/lib/textus/layout.rb CHANGED
@@ -1,9 +1,9 @@
1
1
  module Textus
2
2
  # Single source of truth for every path textus owns under a store root.
3
- # All disposable runtime state nests under <root>/.run/ so the
3
+ # All disposable runtime state nests under <root>/.state/ so the
4
4
  # tracked/disposable boundary is a directory boundary. ADR 0038.
5
5
  module Layout
6
- RUN = ".run"
6
+ RUN = ".state"
7
7
  DATA = "data"
8
8
 
9
9
  def self.data(root)
@@ -18,12 +18,12 @@ module Textus
18
18
  File.join(root, RUN)
19
19
  end
20
20
 
21
- def self.state(root)
22
- File.join(run(root), "state")
21
+ def self.cursors(root)
22
+ File.join(run(root), "cursors")
23
23
  end
24
24
 
25
25
  def self.cursor(root, role)
26
- File.join(state(root), "cursor.#{role}")
26
+ File.join(cursors(root), role.to_s)
27
27
  end
28
28
 
29
29
  def self.locks(root)
@@ -51,19 +51,27 @@ module Textus
51
51
  end
52
52
 
53
53
  # Sentinels are machine-generated (the published target's sha), not authored
54
- # source, so they live on the runtime side under `.run/` — git-ignored,
54
+ # source, so they live on the runtime side under `.state/` — git-ignored,
55
55
  # regenerated by the next build via content-identical adoption (ADR 0070,
56
56
  # superseding ADR 0038's `:config` classification).
57
57
  def self.sentinels(root)
58
58
  File.join(run(root), "sentinels")
59
59
  end
60
60
 
61
+ def self.indexes(root)
62
+ File.join(run(root), "indexes")
63
+ end
64
+
65
+ def self.raw_index(root)
66
+ File.join(indexes(root), "raw.yaml")
67
+ end
68
+
61
69
  def self.audit_log(root)
62
70
  File.join(audit_dir(root), "audit.log")
63
71
  end
64
72
 
65
73
  # The store's `.gitignore` body. Always ignores the runtime subtree
66
- # (`.run/`, ADR 0038); when given untracked entry paths (entries marked
74
+ # (`.state/`, ADR 0038); when given untracked entry paths (entries marked
67
75
  # `tracked: false`), it also lists those so they stay protocol-readable but
68
76
  # uncommitted (ADR 0043, refining 0038). Generated, never hand-kept — no
69
77
  # drift between the manifest and the ignore file.
@@ -45,11 +45,8 @@ module Textus
45
45
 
46
46
  private
47
47
 
48
- # A structured-data entry that textus owns: its `_meta` stays in the
49
- # store, so the published file is the re-serialized meta-free content.
50
- # An external (command) entry is opaque — never parse/re-serialize it.
51
48
  def strip_meta?(entry)
52
- !entry.external? && %w[json yaml].include?(entry.format.to_s)
49
+ %w[json yaml].include?(entry.format.to_s)
53
50
  end
54
51
 
55
52
  def render_bytes(target, content, renderer, pctx)
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require "fileutils"
5
+
6
+ module Textus
7
+ module Ports
8
+ # Content-addressable index for the raw lane. Maps content hashes and URLs
9
+ # to their current canonical key. Lives under <root>/.state/indexes/raw.yaml
10
+ # (gitignored, regenerable — truth is in the on-disk raw entries).
11
+ class RawIndex
12
+ def initialize(root:)
13
+ @root = root
14
+ @path = Layout.raw_index(root)
15
+ end
16
+
17
+ attr_reader :path
18
+
19
+ def load
20
+ return empty_index unless File.exist?(@path)
21
+
22
+ YAML.safe_load_file(@path) || empty_index
23
+ rescue StandardError
24
+ empty_index
25
+ end
26
+
27
+ def save(index)
28
+ FileUtils.mkdir_p(File.dirname(@path))
29
+ File.write(@path, YAML.dump(index))
30
+ index
31
+ end
32
+
33
+ def find_by_hash(content_hash)
34
+ index = load
35
+ index["hashes"]&.fetch(content_hash, nil)
36
+ end
37
+
38
+ def find_by_url(url)
39
+ return nil unless url
40
+
41
+ index = load
42
+ index["urls"]&.fetch(url, nil)
43
+ end
44
+
45
+ def upsert(content_hash:, url:, key:)
46
+ index = load
47
+ index["hashes"] ||= {}
48
+ index["urls"] ||= {}
49
+ index["hashes"][content_hash] = key
50
+ index["urls"][url] = key if url
51
+ save(index)
52
+ end
53
+
54
+ private
55
+
56
+ def empty_index
57
+ { "hashes" => {}, "urls" => {} }
58
+ end
59
+ end
60
+ end
61
+ end
@@ -1,4 +1,4 @@
1
1
  module Textus
2
- VERSION = "0.54.1"
2
+ VERSION = "0.54.2"
3
3
  PROTOCOL = "textus/4"
4
4
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: textus
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.54.1
4
+ version: 0.54.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Patrick
@@ -291,6 +291,7 @@ files:
291
291
  - lib/textus/ports/job_store.rb
292
292
  - lib/textus/ports/job_store/job.rb
293
293
  - lib/textus/ports/publisher.rb
294
+ - lib/textus/ports/raw_index.rb
294
295
  - lib/textus/ports/sentinel_store.rb
295
296
  - lib/textus/ports/storage/file_stat.rb
296
297
  - lib/textus/ports/storage/file_store.rb